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 @@
1
+ """Unit tests for repository scripts."""
@@ -0,0 +1,42 @@
1
+ """Helpers for loading script modules in tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from pathlib import Path
7
+
8
+ from tests.backend.unit.lock_client._helpers import (
9
+ FakeResponse,
10
+ load_lock_client_module,
11
+ make_create_client,
12
+ )
13
+
14
+
15
+ def _find_repo_root() -> Path:
16
+ here = Path(__file__).resolve()
17
+ for parent in here.parents:
18
+ if (parent / "pyproject.toml").exists() and (parent / "scripts").exists():
19
+ return parent
20
+ raise FileNotFoundError("Could not locate repository root")
21
+
22
+
23
+ ROOT = _find_repo_root()
24
+ SCRIPTS_DIR = ROOT / "scripts"
25
+
26
+
27
+ def load_script_module(script_name: str, module_name: str):
28
+ """Load a script file from scripts/ as an importable module object."""
29
+ module_path = SCRIPTS_DIR / script_name
30
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
31
+ assert spec and spec.loader
32
+ mod = importlib.util.module_from_spec(spec)
33
+ spec.loader.exec_module(mod)
34
+ return mod
35
+
36
+
37
+ __all__ = [
38
+ "FakeResponse",
39
+ "load_lock_client_module",
40
+ "load_script_module",
41
+ "make_create_client",
42
+ ]
@@ -0,0 +1,285 @@
1
+ """Tests for scripts/cleanup.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import runpy
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from tests.backend.unit.scripts._helpers import load_script_module
12
+
13
+ cleanup = load_script_module("cleanup.py", "cleanup_under_test")
14
+
15
+
16
+ class TestIsProtected:
17
+ def test_protected_paths(self):
18
+ assert cleanup._is_protected(Path(".venv/something")) is True
19
+ assert cleanup._is_protected(Path("node_modules/pkg")) is True
20
+ assert cleanup._is_protected(Path(".git/objects")) is True
21
+ assert cleanup._is_protected(Path("instance/db")) is True
22
+ assert cleanup._is_protected(Path("test_data/dummy.json")) is True
23
+
24
+ def test_unprotected_paths(self):
25
+ assert cleanup._is_protected(Path("htmlcov/index.html")) is False
26
+ assert cleanup._is_protected(Path(".coverage")) is False
27
+ assert cleanup._is_protected(Path("__pycache__")) is False
28
+
29
+
30
+ class TestRemove:
31
+ def test_remove_nonexistent(self, tmp_path):
32
+ ok, msg = cleanup._remove(tmp_path / "ghost", False)
33
+ assert ok is False
34
+ assert msg == ""
35
+
36
+ def test_remove_file(self, tmp_path, monkeypatch):
37
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
38
+ f = tmp_path / ".coverage"
39
+ f.write_text("data", encoding="utf-8")
40
+ ok, _ = cleanup._remove(f, dry_run=False)
41
+ assert ok is True
42
+ assert not f.exists()
43
+
44
+ def test_remove_directory(self, tmp_path, monkeypatch):
45
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
46
+ d = tmp_path / "htmlcov"
47
+ d.mkdir()
48
+ (d / "index.html").write_text("<html/>", encoding="utf-8")
49
+ ok, _ = cleanup._remove(d, dry_run=False)
50
+ assert ok is True
51
+ assert not d.exists()
52
+
53
+ def test_dry_run_does_not_delete(self, tmp_path, monkeypatch):
54
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
55
+ f = tmp_path / "coverage.xml"
56
+ f.write_text("xml", encoding="utf-8")
57
+ ok, msg = cleanup._remove(f, dry_run=True)
58
+ assert ok is True
59
+ assert "[DRY-RUN]" in msg
60
+ assert f.exists()
61
+
62
+ def test_remove_permission_error(self, tmp_path, monkeypatch):
63
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
64
+ f = tmp_path / "locked"
65
+ f.write_text("x", encoding="utf-8")
66
+ monkeypatch.setattr(Path, "is_dir", lambda self: False)
67
+ monkeypatch.setattr(
68
+ Path,
69
+ "unlink",
70
+ lambda self: (_ for _ in ()).throw(PermissionError("nope")),
71
+ )
72
+ ok, msg = cleanup._remove(f, dry_run=False)
73
+ assert ok is False
74
+ assert "Could not remove" in msg
75
+
76
+
77
+ class TestCleanItemsAndGlob:
78
+ def test_cleans_existing_items(self, tmp_path, monkeypatch):
79
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
80
+ (tmp_path / "htmlcov").mkdir()
81
+ (tmp_path / ".coverage").write_text("data", encoding="utf-8")
82
+ count = cleanup._clean_items(["htmlcov", ".coverage", "nonexistent"], False)
83
+ assert count == 2
84
+
85
+ def test_cleans_matching_patterns(self, tmp_path, monkeypatch):
86
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
87
+ cache = tmp_path / "__pycache__"
88
+ cache.mkdir()
89
+ (cache / "mod.pyc").write_text("", encoding="utf-8")
90
+ count = cleanup._clean_glob(["**/__pycache__"], False)
91
+ assert count >= 1
92
+
93
+ def test_skips_protected(self, tmp_path, monkeypatch):
94
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
95
+ protected = tmp_path / "node_modules"
96
+ protected.mkdir()
97
+ count = cleanup._clean_glob(["node_modules"], False)
98
+ assert count == 0
99
+ assert protected.exists()
100
+
101
+
102
+ class TestCleanFunctions:
103
+ def test_clean_coverage(self, tmp_path, monkeypatch, capsys):
104
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
105
+ (tmp_path / "coverage").mkdir()
106
+ (tmp_path / "coverage" / "lcov.info").write_text("lcov", encoding="utf-8")
107
+ (tmp_path / ".coverage").write_text("data", encoding="utf-8")
108
+ (tmp_path / "coverage.xml").write_text("<xml/>", encoding="utf-8")
109
+ count = cleanup.clean_coverage(dry_run=False)
110
+ assert count >= 3
111
+ assert "Cleaning coverage" in capsys.readouterr().out
112
+
113
+ def test_clean_test_output(self, tmp_path, monkeypatch, capsys):
114
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
115
+ (tmp_path / "test-results").mkdir()
116
+ (tmp_path / "playwright-report").mkdir()
117
+ (tmp_path / "blob-report").mkdir()
118
+ count = cleanup.clean_test_output(dry_run=False)
119
+ assert count >= 3
120
+ assert "Cleaning test output" in capsys.readouterr().out
121
+
122
+ def test_clean_caches(self, tmp_path, monkeypatch, capsys):
123
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
124
+ (tmp_path / ".pytest_cache").mkdir()
125
+ (tmp_path / ".ruff_cache").mkdir()
126
+ count = cleanup.clean_caches(dry_run=False)
127
+ assert count >= 2
128
+ assert "Cleaning tool caches" in capsys.readouterr().out
129
+
130
+ def test_clean_default_and_all(self, tmp_path, monkeypatch):
131
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
132
+ (tmp_path / ".coverage").write_text("data", encoding="utf-8")
133
+ (tmp_path / "test-results").mkdir()
134
+ (tmp_path / ".ruff_cache").mkdir()
135
+ assert cleanup.clean_default(dry_run=False) >= 2
136
+ assert cleanup.clean_all(dry_run=False) >= 1
137
+
138
+
139
+ class TestMainCLI:
140
+ def _run_main(self, monkeypatch, tmp_path, args):
141
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
142
+ monkeypatch.setattr(sys, "argv", ["cleanup.py"] + args)
143
+ return cleanup.main()
144
+
145
+ def test_default_mode(self, monkeypatch, tmp_path, capsys):
146
+ (tmp_path / ".coverage").write_text("x", encoding="utf-8")
147
+ rc = self._run_main(monkeypatch, tmp_path, [])
148
+ assert rc == 0
149
+ assert "test artifacts + coverage" in capsys.readouterr().out
150
+
151
+ def test_switches(self, monkeypatch, tmp_path, capsys):
152
+ (tmp_path / ".ruff_cache").mkdir()
153
+ assert self._run_main(monkeypatch, tmp_path, ["--all"]) == 0
154
+
155
+ (tmp_path / "coverage.xml").write_text("x", encoding="utf-8")
156
+ assert self._run_main(monkeypatch, tmp_path, ["--coverage"]) == 0
157
+
158
+ (tmp_path / "test-results").mkdir()
159
+ assert self._run_main(monkeypatch, tmp_path, ["--tests"]) == 0
160
+
161
+ (tmp_path / ".mypy_cache").mkdir()
162
+ assert self._run_main(monkeypatch, tmp_path, ["--caches"]) == 0
163
+
164
+ out = capsys.readouterr().out
165
+ assert "ARTIFACT CLEANUP" in out
166
+
167
+ def test_main_nothing_to_clean_and_dry_run_banner(
168
+ self,
169
+ monkeypatch,
170
+ tmp_path,
171
+ capsys,
172
+ ):
173
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
174
+ rc = self._run_main(monkeypatch, tmp_path, ["--dry-run"])
175
+ assert rc == 0
176
+ out = capsys.readouterr().out
177
+ assert "[DRY-RUN]" in out
178
+ assert "Nothing to clean" in out
179
+
180
+ def test_packaging_dry_run(self, monkeypatch, tmp_path, capsys):
181
+ # Dry-run should list packaging artifacts without deleting
182
+ (tmp_path / "dist").mkdir()
183
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
184
+ monkeypatch.setattr(sys, "argv", ["cleanup.py", "--packaging", "--dry-run"])
185
+ rc = cleanup.main()
186
+ assert rc == 0
187
+ out = capsys.readouterr().out
188
+ assert "[DRY-RUN]" in out
189
+ assert "Cleaning packaging artifacts" in out
190
+
191
+ def test_packaging_abort_on_no(self, monkeypatch, tmp_path, capsys):
192
+ # If user answers 'no' to confirmation, main should abort with code 2
193
+ (tmp_path / "dist").mkdir()
194
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
195
+ monkeypatch.setattr(sys, "argv", ["cleanup.py", "--packaging"])
196
+ monkeypatch.setattr("builtins.input", lambda prompt: "n")
197
+ rc = cleanup.main()
198
+ assert rc == 2
199
+ out = capsys.readouterr().out
200
+ assert "Aborted by user." in out
201
+
202
+ def test_packaging_confirm_yes_removes(self, monkeypatch, tmp_path, capsys):
203
+ # If user confirms, packaging artifacts should be removed
204
+ d = tmp_path / "dist"
205
+ d.mkdir()
206
+ (tmp_path / "build").mkdir()
207
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
208
+ monkeypatch.setattr(sys, "argv", ["cleanup.py", "--packaging"])
209
+ monkeypatch.setattr("builtins.input", lambda prompt: "y")
210
+ rc = cleanup.main()
211
+ assert rc == 0
212
+ out = capsys.readouterr().out
213
+ assert "Removed" in out or "Would remove" in out
214
+ assert not d.exists()
215
+
216
+
217
+ def test_clean_glob_duplicate_and_value_error_paths(monkeypatch, tmp_path):
218
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
219
+ d = tmp_path / "__pycache__"
220
+ d.mkdir()
221
+
222
+ # Duplicate pattern should hit seen-guard path.
223
+ assert cleanup._clean_glob(["__pycache__", "__pycache__"], False) == 1
224
+
225
+ class _BadPath:
226
+ def __hash__(self):
227
+ return 1
228
+
229
+ def __eq__(self, _other):
230
+ return False
231
+
232
+ def relative_to(self, _root):
233
+ raise ValueError("outside")
234
+
235
+ class _FakeRoot:
236
+ def glob(self, _pattern):
237
+ return [_BadPath()]
238
+
239
+ monkeypatch.setattr(cleanup, "ROOT", _FakeRoot())
240
+ assert cleanup._clean_glob(["anything"], False) == 0
241
+
242
+
243
+ def test_remove_handles_oserror(tmp_path, monkeypatch):
244
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
245
+ target = tmp_path / "problem.txt"
246
+ target.write_text("x", encoding="utf-8")
247
+
248
+ monkeypatch.setattr(
249
+ Path,
250
+ "unlink",
251
+ lambda self: (_ for _ in ()).throw(OSError("busy")),
252
+ )
253
+ ok, msg = cleanup._remove(target, dry_run=False)
254
+ assert ok is False
255
+ assert "Could not remove" in msg
256
+
257
+
258
+ def test_clean_glob_skips_duplicate_entries(monkeypatch, tmp_path):
259
+ duplicate = tmp_path / "dup.pyc"
260
+ duplicate.write_text("data", encoding="utf-8")
261
+
262
+ monkeypatch.setattr(cleanup, "ROOT", tmp_path)
263
+ monkeypatch.setattr(
264
+ cleanup.Path,
265
+ "glob",
266
+ lambda self, _pattern: [duplicate, duplicate] if self == tmp_path else [],
267
+ )
268
+
269
+ removed = []
270
+ monkeypatch.setattr(
271
+ cleanup,
272
+ "_remove",
273
+ lambda path, dry_run: removed.append(path) or (True, ""),
274
+ )
275
+
276
+ count = cleanup._clean_glob(["**/*.pyc"], dry_run=False)
277
+ assert count == 1
278
+ assert removed == [duplicate]
279
+
280
+
281
+ def test_cleanup_module_dunder_main(monkeypatch):
282
+ monkeypatch.setattr(sys, "argv", ["cleanup.py", "--dry-run"])
283
+ with pytest.raises(SystemExit) as exc:
284
+ runpy.run_path("scripts/cleanup.py", run_name="__main__")
285
+ assert exc.value.code == 0
@@ -0,0 +1,280 @@
1
+ """Tests for scripts/collab_git_hook.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ import runpy
9
+ import sys
10
+ from contextlib import redirect_stderr, redirect_stdout
11
+ from pathlib import Path
12
+ from types import SimpleNamespace
13
+
14
+ import pytest
15
+
16
+ from tests.backend.unit.scripts._helpers import load_script_module
17
+
18
+ hook = load_script_module("collab_git_hook.py", "collab_git_hook_under_test")
19
+
20
+
21
+ def test_git_output_success(monkeypatch):
22
+ monkeypatch.setattr(
23
+ hook.subprocess,
24
+ "run",
25
+ lambda *a, **k: SimpleNamespace(returncode=0, stdout="a\n", stderr=""),
26
+ )
27
+ assert hook._git_output("status") == "a"
28
+
29
+
30
+ def test_git_output_error_raises(monkeypatch):
31
+ monkeypatch.setattr(
32
+ hook.subprocess,
33
+ "run",
34
+ lambda *a, **k: SimpleNamespace(returncode=1, stdout="", stderr="boom"),
35
+ )
36
+ try:
37
+ hook._git_output("status")
38
+ assert False
39
+ except RuntimeError as exc:
40
+ assert "boom" in str(exc)
41
+
42
+
43
+ def test_get_staged_files(monkeypatch):
44
+ monkeypatch.setattr(hook, "_git_output", lambda *a: "a.py\n\n b.py \n")
45
+ assert hook._get_staged_files() == ["a.py", "b.py"]
46
+
47
+
48
+ def test_read_pid_file_missing(monkeypatch, tmp_path):
49
+ pid_file = tmp_path / "daemon.pid"
50
+ monkeypatch.setattr("src.lock_client.PID_FILE", str(pid_file))
51
+ assert hook._read_pid_file() is None
52
+
53
+
54
+ def test_read_pid_file_json_and_plain(monkeypatch, tmp_path):
55
+ pid_file = tmp_path / "daemon.pid"
56
+ monkeypatch.setattr("src.lock_client.PID_FILE", str(pid_file))
57
+
58
+ pid_file.write_text(json.dumps({"pid": 123}), encoding="utf-8")
59
+ assert hook._read_pid_file() == 123
60
+
61
+ pid_file.write_text("456", encoding="utf-8")
62
+ assert hook._read_pid_file() == 456
63
+
64
+ pid_file.write_text("not-int", encoding="utf-8")
65
+ assert hook._read_pid_file() is None
66
+
67
+
68
+ def test_read_pid_file_empty_and_oserror(monkeypatch, tmp_path):
69
+ pid_file = tmp_path / "daemon.pid"
70
+ monkeypatch.setattr("src.lock_client.PID_FILE", str(pid_file))
71
+
72
+ pid_file.write_text("\n", encoding="utf-8")
73
+ assert hook._read_pid_file() is None
74
+
75
+ monkeypatch.setattr(
76
+ Path,
77
+ "read_text",
78
+ lambda *a, **k: (_ for _ in ()).throw(OSError("x")),
79
+ )
80
+ assert hook._read_pid_file() is None
81
+
82
+
83
+ def test_pid_is_running_psutil_branch(monkeypatch):
84
+ class _Psutil:
85
+ @staticmethod
86
+ def pid_exists(_pid):
87
+ return True
88
+
89
+ monkeypatch.setitem(sys.modules, "psutil", _Psutil)
90
+ assert hook._pid_is_running(1) is True
91
+
92
+
93
+ def test_pid_is_running_fallback_kill(monkeypatch):
94
+ monkeypatch.setitem(
95
+ sys.modules,
96
+ "psutil",
97
+ SimpleNamespace(pid_exists=lambda _pid: (_ for _ in ()).throw(RuntimeError())),
98
+ )
99
+
100
+ called = {"count": 0}
101
+
102
+ def _fake_kill(pid, sig):
103
+ called["count"] += 1
104
+ if pid == 10:
105
+ raise OSError("gone")
106
+
107
+ monkeypatch.setattr(os, "kill", _fake_kill)
108
+ assert hook._pid_is_running(9) is True
109
+ assert hook._pid_is_running(10) is False
110
+ assert called["count"] >= 2
111
+
112
+
113
+ def test_pid_is_running_permission_error(monkeypatch):
114
+ monkeypatch.setitem(
115
+ sys.modules,
116
+ "psutil",
117
+ SimpleNamespace(pid_exists=lambda _pid: (_ for _ in ()).throw(RuntimeError())),
118
+ )
119
+
120
+ def _fake_kill(_pid, _sig):
121
+ raise PermissionError("denied")
122
+
123
+ monkeypatch.setattr(os, "kill", _fake_kill)
124
+ assert hook._pid_is_running(123) is True
125
+
126
+
127
+ def test_watcher_pid(monkeypatch):
128
+ monkeypatch.setattr(hook, "_read_pid_file", lambda: 77)
129
+ monkeypatch.setattr(hook, "_pid_is_running", lambda pid: True)
130
+ assert hook._watcher_pid() == 77
131
+
132
+ monkeypatch.setattr(hook, "_pid_is_running", lambda pid: False)
133
+ assert hook._watcher_pid() is None
134
+
135
+
136
+ def test_watcher_pid_none_branch(monkeypatch):
137
+ monkeypatch.setattr(hook, "_read_pid_file", lambda: None)
138
+ assert hook._watcher_pid() is None
139
+
140
+
141
+ def test_acquire_staged_no_files(monkeypatch):
142
+ monkeypatch.setattr(hook, "_get_staged_files", lambda: [])
143
+ assert hook.acquire_staged() == 0
144
+
145
+
146
+ def test_acquire_staged_skips_when_watcher_running(monkeypatch):
147
+ monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py"])
148
+ monkeypatch.setattr(hook, "_watcher_pid", lambda: 999)
149
+ err = io.StringIO()
150
+ with redirect_stderr(err):
151
+ rc = hook.acquire_staged()
152
+ assert rc == 0
153
+ assert "Watcher running" in err.getvalue()
154
+
155
+
156
+ def test_acquire_staged_strict_failure(monkeypatch):
157
+ monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py"])
158
+ monkeypatch.setattr(hook, "_watcher_pid", lambda: None)
159
+
160
+ class _BrokenClient:
161
+ def __init__(self):
162
+ raise RuntimeError("lock backend down")
163
+
164
+ monkeypatch.setattr("src.lock_client.LockClient", _BrokenClient)
165
+ monkeypatch.setenv("LOCK_STRICT", "1")
166
+ err = io.StringIO()
167
+ with redirect_stderr(err):
168
+ rc = hook.acquire_staged()
169
+ assert rc == 1
170
+ assert "lock check failed" in err.getvalue()
171
+
172
+
173
+ def test_acquire_staged_conflict(monkeypatch):
174
+ monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py"])
175
+ monkeypatch.setattr(hook, "_watcher_pid", lambda: None)
176
+
177
+ class _Client:
178
+ def acquire_multiple(self, *_a, **_k):
179
+ return False, ["a.py"], "conflict"
180
+
181
+ def get_lock_status(self, _f):
182
+ return {"locked_by": "dev1"}
183
+
184
+ monkeypatch.setattr("src.lock_client.LockClient", lambda: _Client())
185
+
186
+ err = io.StringIO()
187
+ with redirect_stderr(err):
188
+ rc = hook.acquire_staged()
189
+ assert rc == 1
190
+ assert "Commit blocked" in err.getvalue()
191
+ assert "@dev1" in err.getvalue()
192
+
193
+
194
+ def test_acquire_staged_conflict_status_exception(monkeypatch):
195
+ monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py"])
196
+ monkeypatch.setattr(hook, "_watcher_pid", lambda: None)
197
+
198
+ class _Client:
199
+ def acquire_multiple(self, *_a, **_k):
200
+ return False, ["a.py"], "conflict"
201
+
202
+ def get_lock_status(self, _f):
203
+ raise RuntimeError("boom")
204
+
205
+ monkeypatch.setattr("src.lock_client.LockClient", lambda: _Client())
206
+
207
+ err = io.StringIO()
208
+ with redirect_stderr(err):
209
+ rc = hook.acquire_staged()
210
+ assert rc == 1
211
+ assert "@unknown" in err.getvalue()
212
+
213
+
214
+ def test_acquire_staged_success(monkeypatch):
215
+ monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py", "b.py"])
216
+ monkeypatch.setattr(hook, "_watcher_pid", lambda: None)
217
+
218
+ class _Client:
219
+ def acquire_multiple(self, *_a, **_k):
220
+ return True, [], "ok"
221
+
222
+ monkeypatch.setattr("src.lock_client.LockClient", lambda: _Client())
223
+ err = io.StringIO()
224
+ with redirect_stderr(err):
225
+ rc = hook.acquire_staged()
226
+ assert rc == 0
227
+ assert "Locks acquired" in err.getvalue()
228
+
229
+
230
+ def test_release_all_success_and_failure(monkeypatch):
231
+ class _ClientOk:
232
+ def release_all(self):
233
+ return 3
234
+
235
+ monkeypatch.setattr("src.lock_client.LockClient", lambda: _ClientOk())
236
+ err = io.StringIO()
237
+ with redirect_stderr(err):
238
+ assert hook.release_all() == 0
239
+ assert "Released 3" in err.getvalue()
240
+
241
+ class _ClientBad:
242
+ def __init__(self):
243
+ raise RuntimeError("fail")
244
+
245
+ monkeypatch.setattr("src.lock_client.LockClient", _ClientBad)
246
+ err = io.StringIO()
247
+ with redirect_stderr(err):
248
+ assert hook.release_all() == 0
249
+ assert "lock cleanup failed" in err.getvalue()
250
+
251
+
252
+ def test_main_command_dispatch(monkeypatch):
253
+ monkeypatch.setattr(hook, "acquire_staged", lambda: 7)
254
+ monkeypatch.setattr(hook, "release_all", lambda: 8)
255
+
256
+ monkeypatch.setattr(sys, "argv", ["collab_git_hook.py", "acquire-staged"])
257
+ assert hook.main() == 7
258
+
259
+ monkeypatch.setattr(sys, "argv", ["collab_git_hook.py", "release-all"])
260
+ assert hook.main() == 8
261
+
262
+ monkeypatch.setattr(sys, "argv", ["collab_git_hook.py", "unknown"])
263
+ err = io.StringIO()
264
+ with redirect_stderr(err):
265
+ assert hook.main() == 2
266
+ assert "Unknown command" in err.getvalue()
267
+
268
+ monkeypatch.setattr(sys, "argv", ["collab_git_hook.py"])
269
+ out = io.StringIO()
270
+ with redirect_stdout(out):
271
+ assert hook.main() == 2
272
+ assert "Usage" in out.getvalue()
273
+
274
+
275
+ def test_collab_git_hook_dunder_main(monkeypatch):
276
+ monkeypatch.setattr(sys, "argv", ["collab_git_hook.py", "acquire-staged"])
277
+ monkeypatch.setattr(hook, "acquire_staged", lambda: 0)
278
+ with pytest.raises(SystemExit) as exc:
279
+ runpy.run_path("scripts/collab_git_hook.py", run_name="__main__")
280
+ assert exc.value.code == 0
@@ -0,0 +1,50 @@
1
+ """Extra branch coverage tests for scripts/collab_git_hook.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def _repo_root() -> Path:
12
+ here = Path(__file__).resolve()
13
+ for parent in here.parents:
14
+ if (parent / "pyproject.toml").exists() and (parent / "scripts").exists():
15
+ return parent
16
+ raise FileNotFoundError("Could not locate repository root")
17
+
18
+
19
+ def _load_fresh(module_name: str):
20
+ root = _repo_root()
21
+ module_path = root / "scripts" / "collab_git_hook.py"
22
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
23
+ assert spec and spec.loader
24
+ mod = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(mod)
26
+ return mod
27
+
28
+
29
+ def test_src_root_added_to_sys_path(monkeypatch):
30
+ root = _repo_root()
31
+ src_root = str((root / "src").resolve())
32
+ if src_root in sys.path:
33
+ sys.path.remove(src_root)
34
+
35
+ mod = _load_fresh("collab_git_hook_fresh_path")
36
+ assert str(mod.SRC_ROOT) in sys.path
37
+
38
+
39
+ def test_read_pid_file_json_decode_and_non_int_pid(monkeypatch, tmp_path):
40
+ from tests.backend.unit.scripts._helpers import load_script_module
41
+
42
+ hook = load_script_module("collab_git_hook.py", "collab_git_hook_extra_pid")
43
+ pid_file = tmp_path / "daemon.pid"
44
+ monkeypatch.setattr("src.lock_client.PID_FILE", str(pid_file))
45
+
46
+ pid_file.write_text("{bad-json", encoding="utf-8")
47
+ assert hook._read_pid_file() is None
48
+
49
+ pid_file.write_text(json.dumps({"pid": "abc"}), encoding="utf-8")
50
+ assert hook._read_pid_file() is None