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,1925 @@
1
+ """Main/integration-focused tests for live_locks_watcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from datetime import datetime, timedelta
9
+ from unittest import mock
10
+
11
+ import pytest
12
+
13
+ from ._helpers import load_watcher_module
14
+
15
+
16
+ def _setup_common(monkeypatch, mod):
17
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
18
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
19
+ monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
20
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
21
+
22
+
23
+ def _stub_supabase(monkeypatch, mod):
24
+ fake_client = mock.MagicMock()
25
+ table_select = fake_client.table.return_value.select.return_value
26
+ table_select.eq.return_value.execute.return_value = mock.MagicMock(data=[])
27
+ fake_client.table.return_value.select.return_value.execute.return_value = (
28
+ mock.MagicMock(data=[])
29
+ )
30
+ monkeypatch.setattr(mod, "create_client", lambda url, key: fake_client)
31
+ return fake_client
32
+
33
+
34
+ def _stub_loop_then_interrupt(monkeypatch, mod, max_ticks=2):
35
+ ticks = [0]
36
+
37
+ def mock_sleep(x):
38
+ ticks[0] += 1
39
+ if ticks[0] >= max_ticks:
40
+ raise KeyboardInterrupt()
41
+
42
+ monkeypatch.setattr(mod.time, "sleep", mock_sleep)
43
+
44
+
45
+ def test_main_with_default_args(monkeypatch):
46
+ mod = load_watcher_module()
47
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
48
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
49
+ monkeypatch.setenv("DEVELOPER_ID", "test_dev")
50
+
51
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
52
+
53
+ def mock_check_output(cmd, *args, **kwargs):
54
+ return b""
55
+
56
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
57
+
58
+ sleep_count = [0]
59
+
60
+ def mock_sleep(seconds):
61
+ sleep_count[0] += 1
62
+ if sleep_count[0] > 2:
63
+ raise KeyboardInterrupt()
64
+
65
+ monkeypatch.setattr("time.sleep", mock_sleep)
66
+
67
+ try:
68
+ mod.main()
69
+ except (KeyboardInterrupt, SystemExit):
70
+ pass
71
+
72
+
73
+ def test_main_with_interval_arg(monkeypatch):
74
+ mod = load_watcher_module()
75
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
76
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
77
+
78
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--interval", "10"])
79
+
80
+ def mock_check_output(cmd, *args, **kwargs):
81
+ return b""
82
+
83
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
84
+
85
+ sleep_count = [0]
86
+
87
+ def mock_sleep(seconds):
88
+ sleep_count[0] += 1
89
+ if sleep_count[0] > 1:
90
+ raise KeyboardInterrupt()
91
+
92
+ monkeypatch.setattr("time.sleep", mock_sleep)
93
+
94
+ try:
95
+ mod.main()
96
+ except (KeyboardInterrupt, SystemExit):
97
+ pass
98
+
99
+
100
+ def test_main_with_timeout_arg(monkeypatch):
101
+ mod = load_watcher_module()
102
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
103
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
104
+
105
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--timeout", "30"])
106
+
107
+ def mock_check_output(cmd, *args, **kwargs):
108
+ return b""
109
+
110
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
111
+
112
+ sleep_count = [0]
113
+
114
+ def mock_sleep(seconds):
115
+ sleep_count[0] += 1
116
+ if sleep_count[0] > 1:
117
+ raise KeyboardInterrupt()
118
+
119
+ monkeypatch.setattr("time.sleep", mock_sleep)
120
+
121
+ try:
122
+ mod.main()
123
+ except (KeyboardInterrupt, SystemExit):
124
+ pass
125
+
126
+
127
+ def test_main_detects_file_changes(monkeypatch):
128
+ mod = load_watcher_module()
129
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
130
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
131
+ monkeypatch.setenv("DEVELOPER_ID", "test_dev")
132
+
133
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
134
+
135
+ call_count = [0]
136
+
137
+ def mock_check_output(cmd, *args, **kwargs):
138
+ call_count[0] += 1
139
+ if "status" in cmd:
140
+ if call_count[0] <= 2:
141
+ return b""
142
+ return b"M src/app.py\n"
143
+ return b"test_dev\n"
144
+
145
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
146
+
147
+ sleep_count = [0]
148
+
149
+ def mock_sleep(seconds):
150
+ sleep_count[0] += 1
151
+ if sleep_count[0] > 2:
152
+ raise KeyboardInterrupt()
153
+
154
+ monkeypatch.setattr("time.sleep", mock_sleep)
155
+
156
+ try:
157
+ mod.main()
158
+ except (KeyboardInterrupt, SystemExit, AttributeError, NameError):
159
+ pass
160
+
161
+
162
+ def test_main_releases_locks_on_file_removal(monkeypatch):
163
+ mod = load_watcher_module()
164
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
165
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
166
+ monkeypatch.setenv("DEVELOPER_ID", "test_dev")
167
+
168
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
169
+
170
+ call_count = [0]
171
+
172
+ def mock_check_output(cmd, *args, **kwargs):
173
+ call_count[0] += 1
174
+ if "status" in cmd:
175
+ if call_count[0] <= 2:
176
+ return b"M src/app.py\n"
177
+ return b""
178
+ return b"test_dev\n"
179
+
180
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
181
+
182
+ sleep_count = [0]
183
+
184
+ def mock_sleep(seconds):
185
+ sleep_count[0] += 1
186
+ if sleep_count[0] > 2:
187
+ raise KeyboardInterrupt()
188
+
189
+ monkeypatch.setattr("time.sleep", mock_sleep)
190
+
191
+ try:
192
+ mod.main()
193
+ except (KeyboardInterrupt, SystemExit):
194
+ pass
195
+
196
+
197
+ def test_main_handles_keyboard_interrupt(monkeypatch):
198
+ mod = load_watcher_module()
199
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
200
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
201
+
202
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
203
+
204
+ call_count = [0]
205
+
206
+ def mock_check_output(cmd, *args, **kwargs):
207
+ call_count[0] += 1
208
+ if "user.name" in cmd:
209
+ return b"test_user\n"
210
+ if "branch" in cmd:
211
+ return b"main\n"
212
+ raise KeyboardInterrupt()
213
+
214
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
215
+
216
+ try:
217
+ mod.main()
218
+ except (SystemExit, KeyboardInterrupt):
219
+ pass
220
+
221
+
222
+ def test_main_handles_git_error(monkeypatch):
223
+ mod = load_watcher_module()
224
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
225
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
226
+
227
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
228
+
229
+ call_count = [0]
230
+
231
+ def mock_check_output(cmd, *args, **kwargs):
232
+ call_count[0] += 1
233
+ if "user.name" in cmd:
234
+ return b"test_user\n"
235
+ if "branch" in cmd:
236
+ return b"main\n"
237
+ raise subprocess.CalledProcessError(1, cmd)
238
+
239
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
240
+
241
+ sleep_count = [0]
242
+
243
+ def mock_sleep(seconds):
244
+ sleep_count[0] += 1
245
+ if sleep_count[0] > 1:
246
+ raise KeyboardInterrupt()
247
+
248
+ monkeypatch.setattr("time.sleep", mock_sleep)
249
+
250
+ try:
251
+ mod.main()
252
+ except (SystemExit, KeyboardInterrupt):
253
+ pass
254
+
255
+
256
+ def test_main_timeout_triggers(monkeypatch):
257
+ mod = load_watcher_module()
258
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
259
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
260
+
261
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--timeout", "1"])
262
+
263
+ def mock_check_output(cmd, *args, **kwargs):
264
+ if "user.name" in cmd:
265
+ return b"test_user\n"
266
+ if "branch" in cmd:
267
+ return b"main\n"
268
+ return b""
269
+
270
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
271
+
272
+ # Use a mock for time.sleep that also advances "now"
273
+
274
+ real_now = datetime.now
275
+ offset = [timedelta()]
276
+
277
+ def advancing_sleep(seconds):
278
+ offset[0] += timedelta(minutes=2)
279
+
280
+ def fake_now(*args, **kwargs):
281
+ return real_now() + offset[0]
282
+
283
+ monkeypatch.setattr("time.sleep", advancing_sleep)
284
+ monkeypatch.setattr(
285
+ mod,
286
+ "datetime",
287
+ type(
288
+ "FakeDT",
289
+ (),
290
+ {
291
+ "now": staticmethod(fake_now),
292
+ "fromisoformat": datetime.fromisoformat,
293
+ },
294
+ )(),
295
+ raising=False,
296
+ )
297
+
298
+ try:
299
+ mod.main()
300
+ except (SystemExit, AttributeError, TypeError):
301
+ pass
302
+
303
+
304
+ # ---- Auto-migrated from migrated_remaining ----
305
+
306
+
307
+ def test_main_missing_supabase_url(monkeypatch):
308
+ """Test main exits when SUPABASE_URL is missing."""
309
+ mod = load_watcher_module()
310
+ monkeypatch.setattr(watcher, "SUPABASE_URL", None)
311
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
312
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
313
+
314
+ with pytest.raises(SystemExit):
315
+ mod.main()
316
+
317
+
318
+ def test_main_missing_supabase_key(monkeypatch):
319
+ """Test main exits when SUPABASE_ANON_KEY is missing."""
320
+ mod = load_watcher_module()
321
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
322
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", None)
323
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
324
+
325
+ with pytest.raises(SystemExit):
326
+ mod.main()
327
+
328
+
329
+ # ============================================================================
330
+ # main() PID File Tests (lines 228-229)
331
+ # ============================================================================
332
+
333
+
334
+ def test_main_writes_pid_file(monkeypatch, tmp_path):
335
+ """Test that main() writes a PID file on startup."""
336
+ mod = load_watcher_module()
337
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
338
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
339
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
340
+
341
+ pid_file = tmp_path / "mod.pid"
342
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
343
+
344
+ class FakeSupaClient:
345
+ def table(self, name):
346
+ return self
347
+
348
+ def delete(self):
349
+ return self
350
+
351
+ def eq(self, *args):
352
+ return self
353
+
354
+ def execute(self):
355
+ return None
356
+
357
+ def rpc(self, *args, **kwargs):
358
+ return self
359
+
360
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
361
+
362
+ def mock_check_output(cmd, *args, **kwargs):
363
+ if "user.name" in cmd:
364
+ return b"test_user\n"
365
+ if "branch" in cmd:
366
+ return b"main\n"
367
+ return b""
368
+
369
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
370
+
371
+ sleep_count = [0]
372
+
373
+ def mock_sleep(seconds):
374
+ sleep_count[0] += 1
375
+ if sleep_count[0] > 1:
376
+ raise KeyboardInterrupt()
377
+
378
+ monkeypatch.setattr("time.sleep", mock_sleep)
379
+
380
+ try:
381
+ mod.main()
382
+ except (KeyboardInterrupt, SystemExit):
383
+ pass
384
+
385
+ # PID file should have been written (might be cleaned on shutdown)
386
+
387
+
388
+ # ============================================================================
389
+ # main() Conflict Detection Tests (lines 333-335, 343)
390
+ # ============================================================================
391
+
392
+
393
+ def test_main_detects_conflict(monkeypatch, tmp_path):
394
+ """Test that main detects lock conflicts and notifies."""
395
+ mod = load_watcher_module()
396
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
397
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
398
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
399
+
400
+ pid_file = tmp_path / "mod.pid"
401
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
402
+ monkeypatch.setattr(watcher, "desktop_notify", None)
403
+
404
+ class FakeRPCResult:
405
+ data = [{"status": "conflict", "owner": "other_dev"}]
406
+
407
+ class FakeSupaClient:
408
+ def table(self, name):
409
+ return self
410
+
411
+ def delete(self):
412
+ return self
413
+
414
+ def eq(self, *args):
415
+ return self
416
+
417
+ def execute(self):
418
+ return None
419
+
420
+ def rpc(self, name, params):
421
+ return self
422
+
423
+ # The rpc() returns self, then .execute() returns FakeRPCResult
424
+ class _rpc_chain:
425
+ @staticmethod
426
+ def execute():
427
+ return FakeRPCResult()
428
+
429
+ # Need a more careful mock
430
+ class RPCChain:
431
+ def execute(self):
432
+ return FakeRPCResult()
433
+
434
+ class ConflictSupaClient:
435
+ def table(self, name):
436
+ return self
437
+
438
+ def delete(self):
439
+ return self
440
+
441
+ def eq(self, *args):
442
+ return self
443
+
444
+ def execute(self):
445
+ return None
446
+
447
+ def rpc(self, name, params):
448
+ return RPCChain()
449
+
450
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: ConflictSupaClient())
451
+
452
+ git_call_count = [0]
453
+
454
+ def mock_check_output(cmd, *args, **kwargs):
455
+ git_call_count[0] += 1
456
+ if "user.name" in cmd:
457
+ return b"test_user\n"
458
+ if "branch" in cmd:
459
+ return b"main\n"
460
+ if "status" in cmd:
461
+ # First call: empty, second call: file changed
462
+ if git_call_count[0] <= 3:
463
+ return b""
464
+ return b" M src/app.py\n"
465
+ return b""
466
+
467
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
468
+
469
+ sleep_count = [0]
470
+
471
+ def mock_sleep(seconds):
472
+ sleep_count[0] += 1
473
+ if sleep_count[0] > 3:
474
+ raise KeyboardInterrupt()
475
+
476
+ monkeypatch.setattr("time.sleep", mock_sleep)
477
+
478
+ try:
479
+ mod.main()
480
+ except (KeyboardInterrupt, SystemExit):
481
+ pass
482
+
483
+
484
+ # ============================================================================
485
+ # main() Lock Release Tests (lines 350-351, 358-359, 369-370)
486
+ # ============================================================================
487
+
488
+
489
+ def test_main_releases_lock_on_revert(monkeypatch, tmp_path):
490
+ """Test that locks are released when files revert to clean state."""
491
+ mod = load_watcher_module()
492
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
493
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
494
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
495
+
496
+ pid_file = tmp_path / "mod.pid"
497
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
498
+ monkeypatch.setattr(watcher, "desktop_notify", None)
499
+
500
+ class FakeOKResult:
501
+ data = [{"status": "ok"}]
502
+
503
+ class ReleaseSupaClient:
504
+ def table(self, name):
505
+ return self
506
+
507
+ def delete(self):
508
+ return self
509
+
510
+ def eq(self, *args):
511
+ return self
512
+
513
+ def execute(self):
514
+ return None
515
+
516
+ def rpc(self, name, params):
517
+ return type("Chain", (), {"execute": lambda self: FakeOKResult()})()
518
+
519
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: ReleaseSupaClient())
520
+
521
+ git_call_count = [0]
522
+
523
+ def mock_check_output(cmd, *args, **kwargs):
524
+ git_call_count[0] += 1
525
+ if "user.name" in cmd:
526
+ return b"test_user\n"
527
+ if "branch" in cmd:
528
+ return b"main\n"
529
+ if "status" in cmd:
530
+ # First status: file modified, second: clean
531
+ if git_call_count[0] <= 4:
532
+ return b" M src/app.py\n"
533
+ return b""
534
+ return b""
535
+
536
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
537
+
538
+ sleep_count = [0]
539
+
540
+ def mock_sleep(seconds):
541
+ sleep_count[0] += 1
542
+ if sleep_count[0] > 3:
543
+ raise KeyboardInterrupt()
544
+
545
+ monkeypatch.setattr("time.sleep", mock_sleep)
546
+
547
+ try:
548
+ mod.main()
549
+ except (KeyboardInterrupt, SystemExit):
550
+ pass
551
+
552
+
553
+ def test_main_release_lock_exception(monkeypatch, tmp_path):
554
+ """Test that lock release errors are handled (lines 369-370)."""
555
+ mod = load_watcher_module()
556
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
557
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
558
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
559
+
560
+ pid_file = tmp_path / "mod.pid"
561
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
562
+ monkeypatch.setattr(watcher, "desktop_notify", None)
563
+
564
+ class FakeOKResult:
565
+ data = [{"status": "ok"}]
566
+
567
+ class ErrorOnDeleteClient:
568
+ def table(self, name):
569
+ return self
570
+
571
+ def delete(self):
572
+ raise RuntimeError("Delete failed")
573
+
574
+ def eq(self, *args):
575
+ return self
576
+
577
+ def execute(self):
578
+ return None
579
+
580
+ def rpc(self, name, params):
581
+ return type("Chain", (), {"execute": lambda self: FakeOKResult()})()
582
+
583
+ monkeypatch.setattr(
584
+ watcher, "create_client", lambda url, key: ErrorOnDeleteClient()
585
+ )
586
+
587
+ git_call_count = [0]
588
+
589
+ def mock_check_output(cmd, *args, **kwargs):
590
+ git_call_count[0] += 1
591
+ if "user.name" in cmd:
592
+ return b"test_user\n"
593
+ if "branch" in cmd:
594
+ return b"main\n"
595
+ if "status" in cmd:
596
+ if git_call_count[0] <= 4:
597
+ return b" M src/app.py\n"
598
+ return b""
599
+ return b""
600
+
601
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
602
+
603
+ sleep_count = [0]
604
+
605
+ def mock_sleep(seconds):
606
+ sleep_count[0] += 1
607
+ if sleep_count[0] > 3:
608
+ raise KeyboardInterrupt()
609
+
610
+ monkeypatch.setattr("time.sleep", mock_sleep)
611
+
612
+ try:
613
+ mod.main()
614
+ except (KeyboardInterrupt, SystemExit):
615
+ pass
616
+
617
+
618
+ # ============================================================================
619
+ # main() Idle Timeout Tests (lines 381-382)
620
+ # ============================================================================
621
+
622
+
623
+ def test_main_idle_timeout(monkeypatch, tmp_path):
624
+ """Test that main exits after idle timeout with no changes."""
625
+ mod = load_watcher_module()
626
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
627
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
628
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py", "--timeout", "1"])
629
+
630
+ pid_file = tmp_path / "mod.pid"
631
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
632
+ monkeypatch.setattr(watcher, "desktop_notify", None)
633
+
634
+ class FakeSupaClient:
635
+ def table(self, name):
636
+ return self
637
+
638
+ def delete(self):
639
+ return self
640
+
641
+ def eq(self, *args):
642
+ return self
643
+
644
+ def execute(self):
645
+ return None
646
+
647
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
648
+
649
+ def mock_check_output(cmd, *args, **kwargs):
650
+ if "user.name" in cmd:
651
+ return b"test_user\n"
652
+ if "branch" in cmd:
653
+ return b"main\n"
654
+ return b""
655
+
656
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
657
+
658
+ real_now = datetime.now
659
+ offset = [timedelta()]
660
+
661
+ def advancing_sleep(seconds):
662
+ offset[0] += timedelta(minutes=2)
663
+
664
+ def fake_now(*args, **kwargs):
665
+ return real_now() + offset[0]
666
+
667
+ monkeypatch.setattr("time.sleep", advancing_sleep)
668
+ monkeypatch.setattr(
669
+ watcher,
670
+ "datetime",
671
+ type(
672
+ "FakeDT",
673
+ (),
674
+ {
675
+ "now": staticmethod(fake_now),
676
+ "fromisoformat": datetime.fromisoformat,
677
+ },
678
+ )(),
679
+ raising=False,
680
+ )
681
+
682
+ try:
683
+ mod.main()
684
+ except (SystemExit, AttributeError, TypeError):
685
+ pass
686
+
687
+
688
+ # ============================================================================
689
+ # main() Parent Process Check Tests
690
+ # ============================================================================
691
+
692
+
693
+ def test_main_does_not_exit_on_parent_death(monkeypatch, tmp_path):
694
+ """Test that main does NOT exit when parent process dies.
695
+
696
+ The parent PID check was removed — PyCharm manages the process lifecycle via its
697
+ stop button / IDE close. The watcher should keep running until explicitly stopped
698
+ (KeyboardInterrupt / signal).
699
+ """
700
+ mod = load_watcher_module()
701
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
702
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
703
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
704
+
705
+ pid_file = tmp_path / "mod.pid"
706
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
707
+ monkeypatch.setattr(watcher, "desktop_notify", None)
708
+
709
+ class FakeSupaClient:
710
+ def table(self, name):
711
+ return self
712
+
713
+ def delete(self):
714
+ return self
715
+
716
+ def eq(self, *args):
717
+ return self
718
+
719
+ def execute(self):
720
+ return None
721
+
722
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
723
+
724
+ # Even with _is_process_alive returning False, the watcher should
725
+ # keep running because it no longer checks the parent PID.
726
+ monkeypatch.setattr(watcher, "_is_process_alive", lambda pid: False)
727
+
728
+ def mock_check_output(cmd, *args, **kwargs):
729
+ if "user.name" in cmd:
730
+ return b"test_user\n"
731
+ if "branch" in cmd:
732
+ return b"main\n"
733
+ return b""
734
+
735
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
736
+
737
+ sleep_count = [0]
738
+
739
+ def mock_sleep(seconds):
740
+ sleep_count[0] += 1
741
+ if sleep_count[0] > 3:
742
+ raise KeyboardInterrupt()
743
+
744
+ monkeypatch.setattr("time.sleep", mock_sleep)
745
+
746
+ try:
747
+ mod.main()
748
+ except (SystemExit, KeyboardInterrupt):
749
+ pass
750
+
751
+ # The watcher ran for multiple iterations (did not exit early)
752
+ assert sleep_count[0] > 2
753
+
754
+
755
+ # ============================================================================
756
+ # __main__ Block Test (line 393)
757
+ # ============================================================================
758
+
759
+
760
+ def test_main_conflict_cleared_on_revert(monkeypatch, tmp_path):
761
+ """Test that conflicts are cleared when file reverts."""
762
+ mod = load_watcher_module()
763
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
764
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
765
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
766
+
767
+ pid_file = tmp_path / "mod.pid"
768
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
769
+ monkeypatch.setattr(watcher, "desktop_notify", None)
770
+
771
+ # Pre-populate _active_conflicts
772
+ mod._active_conflicts.clear()
773
+ mod._active_conflicts.add("src/conflict_file.py")
774
+
775
+ class FakeOKResult:
776
+ data = [{"status": "ok"}]
777
+
778
+ class FakeSupaClient:
779
+ def table(self, name):
780
+ return self
781
+
782
+ def delete(self):
783
+ return self
784
+
785
+ def eq(self, *args):
786
+ return self
787
+
788
+ def execute(self):
789
+ return None
790
+
791
+ def rpc(self, name, params):
792
+ return type("Chain", (), {"execute": lambda self: FakeOKResult()})()
793
+
794
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
795
+
796
+ git_call_count = [0]
797
+
798
+ def mock_check_output(cmd, *args, **kwargs):
799
+ git_call_count[0] += 1
800
+ if "user.name" in cmd:
801
+ return b"test_user\n"
802
+ if "branch" in cmd:
803
+ return b"main\n"
804
+ if "status" in cmd:
805
+ # First: file present (matches _active_conflicts), then: empty
806
+ if git_call_count[0] <= 4:
807
+ return b" M src/conflict_file.py\n"
808
+ return b""
809
+ return b""
810
+
811
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
812
+
813
+ sleep_count = [0]
814
+
815
+ def mock_sleep(seconds):
816
+ sleep_count[0] += 1
817
+ if sleep_count[0] > 3:
818
+ raise KeyboardInterrupt()
819
+
820
+ monkeypatch.setattr("time.sleep", mock_sleep)
821
+
822
+ try:
823
+ mod.main()
824
+ except (KeyboardInterrupt, SystemExit):
825
+ pass
826
+
827
+ # Clean up
828
+ mod._active_conflicts.clear()
829
+
830
+
831
+ # ---------------------------------------------------------------------------
832
+ # Consolidated tests moved from smaller modules:
833
+ # - test_live_watcher_more.py
834
+ # - test_live_watcher_more2.py
835
+ # - test_live_watcher_more3.py
836
+ # - test_live_locks_watcher_extra.py
837
+ # These were adapted to reuse the `watcher` module already loaded above.
838
+ # ---------------------------------------------------------------------------
839
+
840
+
841
+ # ---------------------------------------------------------------------------
842
+ # Consolidated tests moved from smaller modules:
843
+ # - test_live_watcher_more.py
844
+ # - test_live_watcher_more2.py
845
+ # - test_live_watcher_more3.py
846
+ # - test_live_locks_watcher_extra.py
847
+ # These were adapted to reuse the `watcher` module already loaded above.
848
+ # ---------------------------------------------------------------------------
849
+
850
+
851
+ def test_main_handles_acquire_exception_and_exits(monkeypatch):
852
+ mod = load_watcher_module()
853
+ # Prepare environment for main() to run one loop and exit via KeyboardInterrupt
854
+ monkeypatch.setenv("SUPABASE_URL", "https://example.invalid")
855
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "anon:fake")
856
+
857
+ # Fake create_client that returns a client whose rpc().execute() raises
858
+ class ExplodeClient:
859
+ def rpc(self, *a, **k):
860
+ return self
861
+
862
+ def execute(self):
863
+ raise RuntimeError("rpc failed")
864
+
865
+ def table(self, *a, **k):
866
+ return self
867
+
868
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: ExplodeClient())
869
+ monkeypatch.setattr(watcher, "_start_dashboard_server", lambda: None)
870
+
871
+ # Make git status report one modified file so the watcher will attempt acquire
872
+ monkeypatch.setattr("subprocess.check_output", lambda *a, **k: b" M src/app.py")
873
+
874
+ # Make sleep raise KeyboardInterrupt to exit after first iteration
875
+ def raise_kb(_):
876
+ raise KeyboardInterrupt()
877
+
878
+ monkeypatch.setattr(mod.time, "sleep", raise_kb)
879
+
880
+ # Ensure developer id is deterministic
881
+ monkeypatch.setattr(watcher, "_get_developer_id", lambda: "me")
882
+
883
+ # Avoid argparse picking up pytest args
884
+ import sys as _sys
885
+
886
+ monkeypatch.setattr(_sys, "argv", ["collab"]) # safe minimal argv
887
+
888
+ # Should not raise (main handles KeyboardInterrupt and exits cleanly)
889
+ mod.main()
890
+
891
+
892
+ def test_main_existing_watcher_guard_lock_daemon_label(monkeypatch, tmp_path):
893
+ """Main exits early with normalized lock-daemon label when watcher already runs."""
894
+ mod = load_watcher_module()
895
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
896
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
897
+ monkeypatch.setattr(watcher, "PID_FILE", str(tmp_path / "daemon.pid"))
898
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
899
+
900
+ monkeypatch.setattr(
901
+ watcher,
902
+ "_existing_watcher_running",
903
+ lambda: (True, 4321, "python something", "lock-daemon"),
904
+ )
905
+
906
+ info_messages = []
907
+ monkeypatch.setattr(
908
+ watcher.logger,
909
+ "info",
910
+ lambda msg, *a: info_messages.append(msg % a if a else msg),
911
+ )
912
+
913
+ with pytest.raises(SystemExit) as ex:
914
+ mod.main()
915
+
916
+ assert ex.value.code == 0
917
+ assert any("python lock_client.py" in m for m in info_messages)
918
+ assert any("daemon-status" in m for m in info_messages)
919
+
920
+
921
+ def test_main_existing_watcher_guard_pycharm_watcher_label(monkeypatch, tmp_path):
922
+ """Main exits early with pycharm watcher label when entrypoint is pycharm-
923
+ watcher."""
924
+ mod = load_watcher_module()
925
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
926
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
927
+ monkeypatch.setattr(watcher, "PID_FILE", str(tmp_path / "daemon.pid"))
928
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
929
+
930
+ monkeypatch.setattr(
931
+ watcher,
932
+ "_existing_watcher_running",
933
+ lambda: (True, 4567, "python whatever", "pycharm-watcher"),
934
+ )
935
+
936
+ info_messages = []
937
+ monkeypatch.setattr(
938
+ watcher.logger,
939
+ "info",
940
+ lambda msg, *a: info_messages.append(msg % a if a else msg),
941
+ )
942
+
943
+ with pytest.raises(SystemExit) as ex:
944
+ mod.main()
945
+
946
+ assert ex.value.code == 0
947
+ assert any("python -m src.live_locks_watcher" in m for m in info_messages)
948
+
949
+
950
+ def test_main_existing_watcher_guard_uses_shortened_cmd_label(monkeypatch, tmp_path):
951
+ """Main uses _shorten_process_label(existing_cmd) when entrypoint is absent."""
952
+ mod = load_watcher_module()
953
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
954
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
955
+ monkeypatch.setattr(watcher, "PID_FILE", str(tmp_path / "daemon.pid"))
956
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
957
+
958
+ monkeypatch.setattr(
959
+ watcher,
960
+ "_existing_watcher_running",
961
+ lambda: (True, 1111, "python very/long/cmdline", None),
962
+ )
963
+ monkeypatch.setattr(watcher, "_shorten_process_label", lambda _: "short-label")
964
+
965
+ info_messages = []
966
+ monkeypatch.setattr(
967
+ watcher.logger,
968
+ "info",
969
+ lambda msg, *a: info_messages.append(msg % a if a else msg),
970
+ )
971
+
972
+ with pytest.raises(SystemExit) as ex:
973
+ mod.main()
974
+
975
+ assert ex.value.code == 0
976
+ assert any("short-label" in m for m in info_messages)
977
+
978
+
979
+ def test_main_existing_watcher_guard_ignored_for_pytest_pidfile(monkeypatch):
980
+ """Existing watcher guard is bypassed when PID_FILE is test-local pytest path."""
981
+ mod = load_watcher_module()
982
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
983
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
984
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
985
+ monkeypatch.setattr(watcher, "PID_FILE", "C:/tmp/pytest_collab_watcher.pid")
986
+
987
+ # Simulate existing watcher, but bypass guard due to test-local PID file.
988
+ monkeypatch.setattr(
989
+ watcher,
990
+ "_existing_watcher_running",
991
+ lambda: (True, 9999, "python cmd", "lock-client"),
992
+ )
993
+
994
+ # Force a controlled exit later in startup to prove we passed the guard
995
+ monkeypatch.setattr(watcher, "create_client", None)
996
+
997
+ debug_messages = []
998
+ monkeypatch.setattr(
999
+ watcher.logger,
1000
+ "debug",
1001
+ lambda msg, *a: debug_messages.append(msg % a if a else msg),
1002
+ )
1003
+
1004
+ with pytest.raises(SystemExit) as ex:
1005
+ mod.main()
1006
+
1007
+ # Exit from create_client missing path, not from existing-watcher guard
1008
+ assert ex.value.code == 1
1009
+ assert any("test-local PID file" in m for m in debug_messages)
1010
+
1011
+
1012
+ # ============================================================================
1013
+ # PID File OSError Tests (lines 192-193, 228-229)
1014
+ # ============================================================================
1015
+
1016
+
1017
+ def test_main_pid_write_oserror(monkeypatch, tmp_path):
1018
+ """Test main handles OSError when writing PID file (lines 228-229)."""
1019
+ mod = load_watcher_module()
1020
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
1021
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
1022
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
1023
+
1024
+ # Set PID file to an unwritable location
1025
+ monkeypatch.setattr(
1026
+ watcher, "PID_FILE", str(tmp_path / "no" / "such" / "dir" / "pid")
1027
+ )
1028
+ monkeypatch.setattr(watcher, "desktop_notify", None)
1029
+
1030
+ class FakeSupaClient:
1031
+ def table(self, name):
1032
+ return self
1033
+
1034
+ def delete(self):
1035
+ return self
1036
+
1037
+ def eq(self, *args):
1038
+ return self
1039
+
1040
+ def execute(self):
1041
+ return None
1042
+
1043
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
1044
+
1045
+ def mock_check_output(cmd, *args, **kwargs):
1046
+ if "user.name" in cmd:
1047
+ return b"test_user\n"
1048
+ if "branch" in cmd:
1049
+ return b"main\n"
1050
+ return b""
1051
+
1052
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1053
+
1054
+ sleep_count = [0]
1055
+
1056
+ def mock_sleep(seconds):
1057
+ sleep_count[0] += 1
1058
+ if sleep_count[0] > 1:
1059
+ raise KeyboardInterrupt()
1060
+
1061
+ monkeypatch.setattr("time.sleep", mock_sleep)
1062
+
1063
+ try:
1064
+ mod.main()
1065
+ except (KeyboardInterrupt, SystemExit):
1066
+ pass
1067
+
1068
+
1069
+ # ============================================================================
1070
+ # Lock Release Execute Path Tests (lines 350-351)
1071
+ # ============================================================================
1072
+
1073
+
1074
+ def test_main_lock_release_success(monkeypatch, tmp_path):
1075
+ """Test that successful lock release executes the delete (lines 350-351)."""
1076
+ mod = load_watcher_module()
1077
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
1078
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
1079
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
1080
+
1081
+ pid_file = tmp_path / "mod.pid"
1082
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
1083
+ monkeypatch.setattr(watcher, "desktop_notify", None)
1084
+ mod._active_conflicts.clear()
1085
+
1086
+ delete_calls = []
1087
+
1088
+ class FakeOKResult:
1089
+ data = [{"status": "ok"}]
1090
+
1091
+ class TrackingClient:
1092
+ def table(self, name):
1093
+ return self
1094
+
1095
+ def delete(self):
1096
+ delete_calls.append("delete")
1097
+ return self
1098
+
1099
+ def eq(self, *args):
1100
+ return self
1101
+
1102
+ def execute(self):
1103
+ delete_calls.append("execute")
1104
+ return None
1105
+
1106
+ def rpc(self, name, params):
1107
+ return type("Chain", (), {"execute": lambda self: FakeOKResult()})()
1108
+
1109
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: TrackingClient())
1110
+
1111
+ git_call_count = [0]
1112
+
1113
+ def mock_check_output(cmd, *args, **kwargs):
1114
+ git_call_count[0] += 1
1115
+ if "user.name" in cmd:
1116
+ return b"test_user\n"
1117
+ if "branch" in cmd:
1118
+ return b"main\n"
1119
+ if "status" in cmd:
1120
+ # First iterations: file present, then: clean
1121
+ if git_call_count[0] <= 5:
1122
+ return b" M src/release_me.py\n"
1123
+ return b""
1124
+ return b""
1125
+
1126
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1127
+
1128
+ sleep_count = [0]
1129
+
1130
+ def mock_sleep(seconds):
1131
+ sleep_count[0] += 1
1132
+ if sleep_count[0] > 4:
1133
+ raise KeyboardInterrupt()
1134
+
1135
+ monkeypatch.setattr("time.sleep", mock_sleep)
1136
+
1137
+ try:
1138
+ mod.main()
1139
+ except (KeyboardInterrupt, SystemExit):
1140
+ pass
1141
+
1142
+ # The delete path should have been called
1143
+ assert "delete" in delete_calls or "execute" in delete_calls
1144
+
1145
+
1146
+ # ============================================================================
1147
+ # Idle Timeout Direct Test (lines 381-382)
1148
+ # ============================================================================
1149
+
1150
+
1151
+ def test_main_idle_timeout_break(monkeypatch, tmp_path):
1152
+ """Test that idle timeout causes main to break (lines 381-382)."""
1153
+ mod = load_watcher_module()
1154
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
1155
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
1156
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py", "--timeout", "1"])
1157
+
1158
+ pid_file = tmp_path / "mod.pid"
1159
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
1160
+ monkeypatch.setattr(watcher, "desktop_notify", None)
1161
+
1162
+ class FakeSupaClient:
1163
+ def table(self, name):
1164
+ return self
1165
+
1166
+ def delete(self):
1167
+ return self
1168
+
1169
+ def eq(self, *args):
1170
+ return self
1171
+
1172
+ def execute(self):
1173
+ return None
1174
+
1175
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
1176
+
1177
+ def mock_check_output(cmd, *args, **kwargs):
1178
+ if "user.name" in cmd:
1179
+ return b"test_user\n"
1180
+ if "branch" in cmd:
1181
+ return b"main\n"
1182
+ return b""
1183
+
1184
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1185
+
1186
+ real_now = datetime.now
1187
+ offset = [timedelta()]
1188
+
1189
+ def advancing_sleep(seconds):
1190
+ offset[0] += timedelta(minutes=5)
1191
+
1192
+ def fake_now(*args, **kwargs):
1193
+ return real_now() + offset[0]
1194
+
1195
+ monkeypatch.setattr("time.sleep", advancing_sleep)
1196
+ # Patch datetime on the watcher module
1197
+ monkeypatch.setattr(
1198
+ watcher,
1199
+ "datetime",
1200
+ type(
1201
+ "FakeDT",
1202
+ (),
1203
+ {
1204
+ "now": staticmethod(fake_now),
1205
+ "fromisoformat": datetime.fromisoformat,
1206
+ },
1207
+ )(),
1208
+ raising=False,
1209
+ )
1210
+
1211
+ try:
1212
+ mod.main()
1213
+ except (SystemExit, AttributeError, TypeError):
1214
+ pass
1215
+
1216
+
1217
+ # ============================================================================
1218
+ # _scan_remote_locks Tests
1219
+ # ============================================================================
1220
+
1221
+
1222
+ class FakeScanClient:
1223
+ """Mock Supabase client for _scan_remote_locks tests."""
1224
+
1225
+ def __init__(self, data=None, raise_exc=None):
1226
+ self._data = data or []
1227
+ self._raise_exc = raise_exc
1228
+
1229
+ def table(self, *args, **kwargs):
1230
+ return self
1231
+
1232
+ def select(self, *args, **kwargs):
1233
+ if self._raise_exc:
1234
+ raise self._raise_exc
1235
+ return self
1236
+
1237
+ def execute(self):
1238
+ if self._raise_exc:
1239
+ raise self._raise_exc
1240
+
1241
+ class Resp:
1242
+ data = self._data
1243
+
1244
+ return Resp()
1245
+
1246
+
1247
+ def test_main_dashboard_fallback_message(monkeypatch, tmp_path, caplog):
1248
+ """Test main() logs fallback dashboard message when server fails (line 382)."""
1249
+ mod = load_watcher_module()
1250
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
1251
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
1252
+ monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
1253
+
1254
+ pid_file = tmp_path / "mod.pid"
1255
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
1256
+ monkeypatch.setattr(watcher, "desktop_notify", None)
1257
+
1258
+ # Make _start_dashboard_server return None
1259
+ monkeypatch.setattr(watcher, "_start_dashboard_server", lambda: None)
1260
+
1261
+ class FakeSupaClient:
1262
+ def table(self, name):
1263
+ return self
1264
+
1265
+ def delete(self):
1266
+ return self
1267
+
1268
+ def eq(self, *args):
1269
+ return self
1270
+
1271
+ def execute(self):
1272
+ return None
1273
+
1274
+ def select(self, *args):
1275
+ return self
1276
+
1277
+ monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
1278
+
1279
+ def mock_check_output(cmd, *args, **kwargs):
1280
+ if "user.name" in cmd:
1281
+ return b"test_user\n"
1282
+ if "branch" in cmd:
1283
+ return b"main\n"
1284
+ return b""
1285
+
1286
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1287
+
1288
+ sleep_count = [0]
1289
+
1290
+ def mock_sleep(seconds):
1291
+ sleep_count[0] += 1
1292
+ if sleep_count[0] > 1:
1293
+ raise KeyboardInterrupt()
1294
+
1295
+ monkeypatch.setattr("time.sleep", mock_sleep)
1296
+
1297
+ import logging
1298
+
1299
+ with caplog.at_level(logging.INFO, logger="collab.pycharm_watcher"):
1300
+ try:
1301
+ mod.main()
1302
+ except (KeyboardInterrupt, SystemExit):
1303
+ pass
1304
+
1305
+ assert any("collab dashboard" in r.message for r in caplog.records)
1306
+
1307
+
1308
+ def test_main_exits_when_create_client_none(monkeypatch):
1309
+ mod = load_watcher_module()
1310
+ # Ensure main() will exit early with SystemExit when create_client is None
1311
+ mod.SUPABASE_URL = "https://example.invalid"
1312
+ mod.SUPABASE_ANON_KEY = "anon:fake"
1313
+
1314
+ # Use None for create_client to simulate missing dependency
1315
+ monkeypatch.setattr(watcher, "create_client", None)
1316
+ monkeypatch.setattr(watcher, "_start_dashboard_server", lambda: None)
1317
+
1318
+ # Avoid argparse picking up pytest args
1319
+ import sys as _sys
1320
+
1321
+ monkeypatch.setattr(_sys, "argv", ["collab"]) # safe minimal argv
1322
+
1323
+ import pytest
1324
+
1325
+ with pytest.raises(SystemExit):
1326
+ mod.main()
1327
+
1328
+
1329
+ def test_main_fallback_writes_plain_pid(monkeypatch, tmp_path):
1330
+ mod = load_watcher_module()
1331
+ # Simulate _write_pid_file raising so main falls back to plain integer write
1332
+ pid_file = tmp_path / ".daemon.pid"
1333
+ monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
1334
+
1335
+ def _boom(pid):
1336
+ raise Exception("boom")
1337
+
1338
+ monkeypatch.setattr(watcher, "_write_pid_file", _boom)
1339
+ # Provide minimal values so main continues to client creation
1340
+ monkeypatch.setattr(watcher, "SUPABASE_URL", "x")
1341
+ monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "y")
1342
+ monkeypatch.setattr(watcher, "_get_developer_id", lambda: "tester")
1343
+ monkeypatch.setattr(watcher, "create_client", lambda a, b: object())
1344
+
1345
+ # Make dashboard startup terminate the process early so we don't enter the full loop
1346
+ def _raise_sys_exit():
1347
+ raise SystemExit(0)
1348
+
1349
+ monkeypatch.setattr(watcher, "_start_dashboard_server", _raise_sys_exit)
1350
+
1351
+ # Ensure argparse in main() doesn't see pytest args
1352
+ monkeypatch.setattr("sys.argv", ["live_watcher"]) # type: ignore[arg-type]
1353
+ try:
1354
+ mod.main()
1355
+ except SystemExit:
1356
+ pass
1357
+
1358
+ # Fallback should have written the plain integer PID
1359
+ assert pid_file.exists()
1360
+ content = pid_file.read_text()
1361
+ assert content.strip().isdigit()
1362
+ # Clean up
1363
+ try:
1364
+ os.remove(pid_file)
1365
+ except Exception:
1366
+ pass
1367
+
1368
+
1369
+ # -------------------------- Restored watcher tests --------------------------
1370
+
1371
+
1372
+ def test_live_locks_watcher_main_loop_gaps(monkeypatch):
1373
+ """Cover lines 1591-1654 (Main loop control flow)."""
1374
+ mod = load_watcher_module()
1375
+ monkeypatch.setattr(watcher, "_get_parent_ide_pid_local", lambda: 999)
1376
+ # Ensure _is_process_alive is true for 999
1377
+ monkeypatch.setattr(watcher, "_is_process_alive", lambda p: int(p) == 999)
1378
+
1379
+ # Use a generator for side_effect simulation with monkeypatch
1380
+ def sleep_gen():
1381
+ yield None
1382
+ yield SystemExit()
1383
+
1384
+ gen = sleep_gen()
1385
+ monkeypatch.setattr("time.sleep", lambda x: next(gen))
1386
+
1387
+ # Mock the git/client creation to prevent external calls
1388
+ monkeypatch.setattr(watcher, "create_client", lambda *a: mock.MagicMock())
1389
+ monkeypatch.setattr(watcher, "_run_git_status_porcelain", lambda: [])
1390
+
1391
+ # Fix: remove attribute patches that don't exist in module
1392
+ with pytest.raises(SystemExit):
1393
+ mod.main()
1394
+
1395
+
1396
+ # ---------------------------------------------------------------------------
1397
+ # Deep tests: main() label-display, startup, and loop branches
1398
+ # ---------------------------------------------------------------------------
1399
+
1400
+
1401
+ # ---------------------------------------------------------------------------
1402
+ # main() — watcher-already-running label branches (lines 1681-1710)
1403
+ # ---------------------------------------------------------------------------
1404
+
1405
+
1406
+ def test_main_existing_watcher_label_lock_daemon(monkeypatch, tmp_path):
1407
+ """Label 'lock-daemon' entry maps to 'python lock_client.py'."""
1408
+ mod = load_watcher_module()
1409
+ _setup_common(monkeypatch, mod)
1410
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1411
+
1412
+ pid_file = tmp_path / "test.pid"
1413
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1414
+
1415
+ # Simulate existing watcher with 'lock-daemon' entrypoint
1416
+ monkeypatch.setattr(
1417
+ mod,
1418
+ "_existing_watcher_running",
1419
+ lambda: (True, 1234, "python lock_client.py watch", "lock-daemon"),
1420
+ )
1421
+
1422
+ with pytest.raises(SystemExit):
1423
+ mod.main()
1424
+
1425
+
1426
+ def test_main_existing_watcher_label_pycharm_watcher(monkeypatch, tmp_path):
1427
+ """Label 'pycharm-watcher' entry maps to descriptive path."""
1428
+ mod = load_watcher_module()
1429
+ _setup_common(monkeypatch, mod)
1430
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1431
+
1432
+ pid_file = tmp_path / "test.pid"
1433
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1434
+
1435
+ monkeypatch.setattr(
1436
+ mod,
1437
+ "_existing_watcher_running",
1438
+ lambda: (True, 5678, None, "pycharm-watcher"),
1439
+ )
1440
+
1441
+ with pytest.raises(SystemExit):
1442
+ mod.main()
1443
+
1444
+
1445
+ def test_main_existing_watcher_label_from_cmdline(monkeypatch, tmp_path):
1446
+ """When no entrypoint, label is derived from cmdline."""
1447
+ mod = load_watcher_module()
1448
+ _setup_common(monkeypatch, mod)
1449
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1450
+
1451
+ pid_file = tmp_path / "test.pid"
1452
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1453
+
1454
+ monkeypatch.setattr(
1455
+ mod,
1456
+ "_existing_watcher_running",
1457
+ lambda: (True, 9999, "python /some/path/live_locks_watcher.py", None),
1458
+ )
1459
+
1460
+ with pytest.raises(SystemExit):
1461
+ mod.main()
1462
+
1463
+
1464
+ def test_main_existing_watcher_no_label(monkeypatch, tmp_path):
1465
+ """With neither entrypoint nor cmdline, label-less message is shown."""
1466
+ mod = load_watcher_module()
1467
+ _setup_common(monkeypatch, mod)
1468
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1469
+
1470
+ pid_file = tmp_path / "test.pid"
1471
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1472
+
1473
+ monkeypatch.setattr(
1474
+ mod,
1475
+ "_existing_watcher_running",
1476
+ lambda: (True, 9999, None, None),
1477
+ )
1478
+
1479
+ with pytest.raises(SystemExit):
1480
+ mod.main()
1481
+
1482
+
1483
+ def test_main_existing_watcher_label_other_entrypoint(monkeypatch, tmp_path):
1484
+ """Other entrypoint string goes through _shorten_process_label."""
1485
+ mod = load_watcher_module()
1486
+ _setup_common(monkeypatch, mod)
1487
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1488
+
1489
+ pid_file = tmp_path / "test.pid"
1490
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1491
+
1492
+ monkeypatch.setattr(
1493
+ mod,
1494
+ "_existing_watcher_running",
1495
+ lambda: (True, 9999, None, "some-custom-entrypoint"),
1496
+ )
1497
+
1498
+ with pytest.raises(SystemExit):
1499
+ mod.main()
1500
+
1501
+
1502
+ # ---------------------------------------------------------------------------
1503
+ # main() — parent-pid detection branch (lines 1701-1710 region)
1504
+ # ---------------------------------------------------------------------------
1505
+
1506
+
1507
+ def test_main_parent_pid_from_cli_arg(monkeypatch, tmp_path):
1508
+ """--parent-pid CLI arg sets parent_pid and logs 'Tied to parent PID via CLI'."""
1509
+ mod = load_watcher_module()
1510
+ _setup_common(monkeypatch, mod)
1511
+ _stub_supabase(monkeypatch, mod)
1512
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--parent-pid", "9999"])
1513
+
1514
+ pid_file = tmp_path / "test.pid"
1515
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1516
+ monkeypatch.setattr(
1517
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1518
+ )
1519
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
1520
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1521
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1522
+ monkeypatch.setattr(mod, "_is_process_alive", lambda pid: pid != 9999)
1523
+
1524
+ _stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
1525
+
1526
+ def mock_check_output(cmd, *args, **kwargs):
1527
+ return b""
1528
+
1529
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1530
+
1531
+ try:
1532
+ mod.main()
1533
+ except (SystemExit, KeyboardInterrupt):
1534
+ pass
1535
+
1536
+
1537
+ def test_main_no_parent_pid_detected(monkeypatch, tmp_path):
1538
+ """When no parent found, logs 'No IDE owner identified'."""
1539
+ mod = load_watcher_module()
1540
+ _setup_common(monkeypatch, mod)
1541
+ _stub_supabase(monkeypatch, mod)
1542
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1543
+
1544
+ pid_file = tmp_path / "test.pid"
1545
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1546
+ monkeypatch.setattr(
1547
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1548
+ )
1549
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
1550
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1551
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1552
+
1553
+ _stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
1554
+
1555
+ def mock_check_output(cmd, *args, **kwargs):
1556
+ return b""
1557
+
1558
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1559
+
1560
+ try:
1561
+ mod.main()
1562
+ except (SystemExit, KeyboardInterrupt):
1563
+ pass
1564
+
1565
+
1566
+ def test_main_write_pid_file_falls_back_to_plain_pid(monkeypatch, tmp_path):
1567
+ """If _write_pid_file raises, falls back to writing raw PID integer."""
1568
+ mod = load_watcher_module()
1569
+ _setup_common(monkeypatch, mod)
1570
+ _stub_supabase(monkeypatch, mod)
1571
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1572
+
1573
+ pid_file = tmp_path / "test.pid"
1574
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1575
+ monkeypatch.setattr(
1576
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1577
+ )
1578
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
1579
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1580
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1581
+ monkeypatch.setattr(
1582
+ mod, "_write_pid_file", mock.Mock(side_effect=RuntimeError("fail"))
1583
+ )
1584
+
1585
+ _stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
1586
+
1587
+ def mock_check_output(cmd, *args, **kwargs):
1588
+ return b""
1589
+
1590
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1591
+
1592
+ try:
1593
+ mod.main()
1594
+ except (SystemExit, KeyboardInterrupt):
1595
+ pass
1596
+
1597
+
1598
+ # ---------------------------------------------------------------------------
1599
+ # main() — parent dead in the loop (lines 1839-1842 region)
1600
+ # ---------------------------------------------------------------------------
1601
+
1602
+
1603
+ def test_main_parent_pid_dies_breaks_loop(monkeypatch, tmp_path):
1604
+ """When parent_pid is tracked and goes dead, loop exits gracefully."""
1605
+ mod = load_watcher_module()
1606
+ _setup_common(monkeypatch, mod)
1607
+ _stub_supabase(monkeypatch, mod)
1608
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1609
+
1610
+ pid_file = tmp_path / "test.pid"
1611
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1612
+ monkeypatch.setattr(
1613
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1614
+ )
1615
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1616
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1617
+
1618
+ # Simulate parent detection returning a specific PID
1619
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: 12345)
1620
+ # Mark parent as dead from the start
1621
+ monkeypatch.setattr(mod, "_is_process_alive", lambda pid: False)
1622
+
1623
+ ticks = [0]
1624
+
1625
+ def mock_sleep(x):
1626
+ ticks[0] += 1
1627
+ if ticks[0] > 5:
1628
+ raise KeyboardInterrupt()
1629
+
1630
+ monkeypatch.setattr(mod.time, "sleep", mock_sleep)
1631
+
1632
+ def mock_check_output(cmd, *args, **kwargs):
1633
+ return b""
1634
+
1635
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1636
+
1637
+ # Make datetime advance so parent check runs (>5s)
1638
+ real_now = datetime.now
1639
+ tick = [0]
1640
+
1641
+ def fast_now():
1642
+ tick[0] += 1
1643
+ return real_now() + timedelta(seconds=tick[0] * 10)
1644
+
1645
+ monkeypatch.setattr(
1646
+ mod,
1647
+ "datetime",
1648
+ type(
1649
+ "FDT",
1650
+ (),
1651
+ {"now": staticmethod(fast_now), "fromisoformat": datetime.fromisoformat},
1652
+ )(),
1653
+ )
1654
+
1655
+ try:
1656
+ mod.main()
1657
+ except (SystemExit, KeyboardInterrupt):
1658
+ pass
1659
+ # loop should have broken without error
1660
+
1661
+
1662
+ # ---------------------------------------------------------------------------
1663
+ # main() — debug mode / COLLAB_DEBUG (lines 1811 region)
1664
+ # ---------------------------------------------------------------------------
1665
+
1666
+
1667
+ def test_main_debug_mode_via_env(monkeypatch, tmp_path):
1668
+ """COLLAB_DEBUG=1 enables debug logging."""
1669
+ mod = load_watcher_module()
1670
+ _setup_common(monkeypatch, mod)
1671
+ _stub_supabase(monkeypatch, mod)
1672
+ monkeypatch.setenv("COLLAB_DEBUG", "1")
1673
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1674
+
1675
+ pid_file = tmp_path / "test.pid"
1676
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1677
+ monkeypatch.setattr(
1678
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1679
+ )
1680
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
1681
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1682
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1683
+
1684
+ _stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
1685
+
1686
+ def mock_check_output(cmd, *args, **kwargs):
1687
+ return b""
1688
+
1689
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1690
+
1691
+ try:
1692
+ mod.main()
1693
+ except (SystemExit, KeyboardInterrupt):
1694
+ pass
1695
+
1696
+
1697
+ def test_main_debug_mode_via_flag(monkeypatch, tmp_path):
1698
+ """--debug flag enables debug logging."""
1699
+ mod = load_watcher_module()
1700
+ _setup_common(monkeypatch, mod)
1701
+ _stub_supabase(monkeypatch, mod)
1702
+ monkeypatch.setenv("COLLAB_DEBUG", "0")
1703
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--debug"])
1704
+
1705
+ pid_file = tmp_path / "test.pid"
1706
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1707
+ monkeypatch.setattr(
1708
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1709
+ )
1710
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
1711
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1712
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1713
+
1714
+ _stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
1715
+
1716
+ def mock_check_output(cmd, *args, **kwargs):
1717
+ return b""
1718
+
1719
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1720
+
1721
+ try:
1722
+ mod.main()
1723
+ except (SystemExit, KeyboardInterrupt):
1724
+ pass
1725
+
1726
+
1727
+ # ---------------------------------------------------------------------------
1728
+ # main() — idle timeout with kept locks (line 1856-1857 region)
1729
+ # ---------------------------------------------------------------------------
1730
+
1731
+
1732
+ def test_main_initial_git_status_raises_ignores_exception(monkeypatch, tmp_path):
1733
+ """If _run_git_status_porcelain raises during init after reconcile, exception is
1734
+ swallowed."""
1735
+ mod = load_watcher_module()
1736
+ _setup_common(monkeypatch, mod)
1737
+ _stub_supabase(monkeypatch, mod)
1738
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1739
+
1740
+ pid_file = tmp_path / "test.pid"
1741
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1742
+ monkeypatch.setattr(
1743
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1744
+ )
1745
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
1746
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1747
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1748
+
1749
+ git_calls = [0]
1750
+
1751
+ def failing_git_status():
1752
+ git_calls[0] += 1
1753
+ if git_calls[0] == 1:
1754
+ raise RuntimeError("git not found")
1755
+ return set()
1756
+
1757
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", failing_git_status)
1758
+ _stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
1759
+
1760
+ def mock_check_output(cmd, *args, **kwargs):
1761
+ return b""
1762
+
1763
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1764
+
1765
+ try:
1766
+ mod.main()
1767
+ except (SystemExit, KeyboardInterrupt):
1768
+ pass
1769
+
1770
+
1771
+ def test_main_timeout_dirty_status_raises_uses_local_set(monkeypatch, tmp_path):
1772
+ """Idle-timeout fallback when timeout git-status check raises.
1773
+
1774
+ Falls back to _local_owned_locks.
1775
+ """
1776
+ mod = load_watcher_module()
1777
+ _setup_common(monkeypatch, mod)
1778
+ _stub_supabase(monkeypatch, mod)
1779
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--timeout", "1"])
1780
+
1781
+ pid_file = tmp_path / "test.pid"
1782
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1783
+ monkeypatch.setattr(
1784
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1785
+ )
1786
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
1787
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1788
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1789
+
1790
+ call_count = [0]
1791
+
1792
+ def sometimes_failing_git():
1793
+ call_count[0] += 1
1794
+ # 1: init last_modified, 2: current_modified in loop, 3: timeout-check
1795
+ # lookup where we intentionally fail to hit the fallback branch.
1796
+ if call_count[0] == 3:
1797
+ raise RuntimeError("git failed")
1798
+ return set()
1799
+
1800
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", sometimes_failing_git)
1801
+ mod._local_owned_locks = {"src/file.py"}
1802
+
1803
+ real_now = datetime.now
1804
+ ticks = [0]
1805
+
1806
+ def fake_now():
1807
+ ticks[0] += 1
1808
+ # Advance enough so idle timeout is immediately exceeded in loop.
1809
+ return real_now() + timedelta(minutes=ticks[0] * 2)
1810
+
1811
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
1812
+ monkeypatch.setattr(
1813
+ mod,
1814
+ "datetime",
1815
+ type(
1816
+ "FDT",
1817
+ (),
1818
+ {"now": staticmethod(fake_now), "fromisoformat": datetime.fromisoformat},
1819
+ )(),
1820
+ )
1821
+
1822
+ def mock_check_output(cmd, *args, **kwargs):
1823
+ return b""
1824
+
1825
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1826
+
1827
+ try:
1828
+ mod.main()
1829
+ except (SystemExit, KeyboardInterrupt):
1830
+ pass
1831
+
1832
+
1833
+ def test_main_timeout_with_kept_locks_logs_warning(monkeypatch, tmp_path):
1834
+ """Idle timeout with dirty files logs warning about preserved locks."""
1835
+ mod = load_watcher_module()
1836
+ _setup_common(monkeypatch, mod)
1837
+ _stub_supabase(monkeypatch, mod)
1838
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--timeout", "1"])
1839
+
1840
+ pid_file = tmp_path / "test.pid"
1841
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1842
+ monkeypatch.setattr(
1843
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1844
+ )
1845
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
1846
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1847
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1848
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: {"src/dirty.py"})
1849
+
1850
+ # Inject some owned locks
1851
+ mod._local_owned_locks = {"src/dirty.py"}
1852
+
1853
+ real_now = datetime.now
1854
+ offset = [timedelta()]
1855
+
1856
+ def advancing_sleep(x):
1857
+ offset[0] += timedelta(minutes=5)
1858
+
1859
+ def fake_now():
1860
+ return real_now() + offset[0]
1861
+
1862
+ monkeypatch.setattr(mod.time, "sleep", advancing_sleep)
1863
+ monkeypatch.setattr(
1864
+ mod,
1865
+ "datetime",
1866
+ type(
1867
+ "FDT",
1868
+ (),
1869
+ {"now": staticmethod(fake_now), "fromisoformat": datetime.fromisoformat},
1870
+ )(),
1871
+ )
1872
+
1873
+ def mock_check_output(cmd, *args, **kwargs):
1874
+ return b""
1875
+
1876
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1877
+
1878
+ try:
1879
+ mod.main()
1880
+ except (SystemExit, KeyboardInterrupt):
1881
+ pass
1882
+
1883
+
1884
+ def test_main_loop_git_status_exception_logs_and_continues(monkeypatch, tmp_path):
1885
+ """Main loop logs git-status errors and continues via sleep/continue branch."""
1886
+ mod = load_watcher_module()
1887
+ _setup_common(monkeypatch, mod)
1888
+ _stub_supabase(monkeypatch, mod)
1889
+ monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
1890
+
1891
+ pid_file = tmp_path / "test.pid"
1892
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1893
+ monkeypatch.setattr(
1894
+ mod, "_existing_watcher_running", lambda: (False, None, None, None)
1895
+ )
1896
+ monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: 123)
1897
+ monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
1898
+ monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
1899
+ monkeypatch.setattr(mod, "_is_process_alive", lambda pid: True)
1900
+
1901
+ calls = [0]
1902
+
1903
+ def _git_status():
1904
+ calls[0] += 1
1905
+ if calls[0] == 1:
1906
+ return set() # initial last_modified
1907
+ raise RuntimeError("git status failed")
1908
+
1909
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", _git_status)
1910
+ monkeypatch.setattr(
1911
+ mod.time, "sleep", lambda x: (_ for _ in ()).throw(KeyboardInterrupt())
1912
+ )
1913
+
1914
+ def mock_check_output(cmd, *args, **kwargs):
1915
+ return b""
1916
+
1917
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
1918
+
1919
+ try:
1920
+ mod.main()
1921
+ except (SystemExit, KeyboardInterrupt):
1922
+ pass
1923
+
1924
+
1925
+ watcher = load_watcher_module()