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,305 @@
1
+ """Tests for scripts/generate_tests.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import runpy
6
+ import sys
7
+
8
+ import pytest
9
+
10
+ from tests.backend.unit.scripts._helpers import load_script_module
11
+
12
+ gen = load_script_module("generate_tests.py", "generate_tests_under_test")
13
+
14
+
15
+ class TestCodeAnalyzer:
16
+ def test_analyze_finds_public_functions_and_classes(self, tmp_path):
17
+ src = tmp_path / "sample.py"
18
+ src.write_text(
19
+ "class Foo:\n pass\n\n"
20
+ "def bar():\n pass\n\n"
21
+ "def _private():\n pass\n",
22
+ encoding="utf-8",
23
+ )
24
+ analyzer = gen.CodeAnalyzer(str(src))
25
+ entities = analyzer.analyze()
26
+ names = [e[0] for e in entities]
27
+ assert "Foo" in names
28
+ assert "bar" in names
29
+ assert "_private" not in names
30
+
31
+ def test_analyze_async_and_bom_and_syntax_error(self, tmp_path, capsys):
32
+ src = tmp_path / "async_mod.py"
33
+ src.write_text("async def fetch():\n return 1\n", encoding="utf-8")
34
+ assert gen.CodeAnalyzer(str(src)).analyze() == [("fetch", "function")]
35
+
36
+ bom = tmp_path / "bom_mod.py"
37
+ bom.write_text("\ufeffdef hello():\n return 'hi'\n", encoding="utf-8")
38
+ assert gen.CodeAnalyzer(str(bom)).analyze() == [("hello", "function")]
39
+
40
+ bad = tmp_path / "bad.py"
41
+ bad.write_text("def broken(:\n pass\n", encoding="utf-8")
42
+ assert gen.CodeAnalyzer(str(bad)).analyze() == []
43
+ assert "Syntax error" in capsys.readouterr().out
44
+
45
+
46
+ class TestTestGenerator:
47
+ def test_detect_category(self):
48
+ assert gen.TestGenerator("src/routes/api.py").category == "functional"
49
+ assert gen.TestGenerator("src/lock_client.py").category == "unit"
50
+ assert gen.TestGenerator("src/unknown.py").category == "unit"
51
+
52
+ def test_generate_empty_entities(self):
53
+ tg = gen.TestGenerator("src/foo.py")
54
+ assert tg.generate([]) == ""
55
+
56
+ def test_generate_import_block_for_src(self):
57
+ tg = gen.TestGenerator("src/my_module.py")
58
+ code = tg.generate([("MyClass", "class"), ("do_it", "function")])
59
+ assert "import pytest" in code
60
+ assert "from src.my_module import (" in code
61
+ assert "class TestMyClass:" in code
62
+ assert "test_do_it_is_callable" in code
63
+
64
+ def test_generate_path_loader_block_for_scripts(self):
65
+ tg = gen.TestGenerator("scripts/cleanup.py")
66
+ code = tg.generate([("clean_default", "function")])
67
+ assert "import importlib.util" in code
68
+ assert "def _find_repo_root() -> Path:" in code
69
+ assert "module_under_test = _load_module()" in code
70
+ assert "clean_default = module_under_test.clean_default" in code
71
+
72
+ def test_get_import_path(self, tmp_path):
73
+ tg = gen.TestGenerator("src/foo.py")
74
+ assert tg._get_import_path() == "foo"
75
+
76
+ tg2 = gen.TestGenerator("other/bar.py")
77
+ assert tg2._get_import_path().endswith("bar")
78
+
79
+ external_src = tmp_path / "src" / "pkg" / "mod.py"
80
+ external_src.parent.mkdir(parents=True, exist_ok=True)
81
+ external_src.write_text("x = 1\n", encoding="utf-8")
82
+ tg3 = gen.TestGenerator(
83
+ str(external_src), repo_root=tmp_path / "different-root"
84
+ )
85
+ tg3.relative_source_path = None
86
+ assert tg3._get_import_path() == "pkg.mod"
87
+
88
+ external_no_src = tmp_path / "outside" / "simple.py"
89
+ external_no_src.parent.mkdir(parents=True, exist_ok=True)
90
+ external_no_src.write_text("x = 1\n", encoding="utf-8")
91
+ tg4 = gen.TestGenerator(
92
+ str(external_no_src), repo_root=tmp_path / "another-root"
93
+ )
94
+ tg4.relative_source_path = None
95
+ assert tg4._get_import_path() == "simple"
96
+
97
+ def test_direct_import_module_extra_branches(self, tmp_path):
98
+ tg = gen.TestGenerator("run.py")
99
+ assert tg._get_direct_import_module() == "run"
100
+
101
+ # Force external-path branch with src/ in the absolute path.
102
+ external = tmp_path / "src" / "nested" / "mod.py"
103
+ external.parent.mkdir(parents=True, exist_ok=True)
104
+ external.write_text("def x():\n return 1\n", encoding="utf-8")
105
+ tg2 = gen.TestGenerator(str(external), repo_root=tmp_path / "other-root")
106
+ assert tg2._get_direct_import_module() == "src.nested.mod"
107
+
108
+ def test_generate_adds_blank_line_when_import_block_has_no_trailing_empty(self):
109
+ tg = gen.TestGenerator("src/my_mod.py")
110
+ tg._build_import_block = ( # type: ignore[method-assign]
111
+ lambda _names: ["import pytest"]
112
+ )
113
+ code = tg.generate([("Foo", "class")])
114
+ assert "\n\nclass TestFoo:" in code
115
+
116
+ def test_build_module_path_expression_for_external_source(self, tmp_path):
117
+ ext = tmp_path / "external.py"
118
+ ext.write_text("x=1\n", encoding="utf-8")
119
+ tg = gen.TestGenerator(str(ext), repo_root=tmp_path / "repo2")
120
+ expr = tg._build_module_path_expression()
121
+ assert "Path(" in expr
122
+
123
+ def test_get_test_dir_and_file(self, tmp_path):
124
+ src = tmp_path / "src" / "collab"
125
+ src.mkdir(parents=True)
126
+ source_file = src / "foo.py"
127
+ source_file.write_text("def x():\n return 1\n", encoding="utf-8")
128
+
129
+ tg = gen.TestGenerator(str(source_file), repo_root=tmp_path)
130
+ assert tg.get_test_dir() == tmp_path / "tests" / "backend" / "unit" / "collab"
131
+ assert tg.get_test_file() == (
132
+ tmp_path / "tests" / "backend" / "unit" / "collab" / "test_foo.py"
133
+ )
134
+
135
+ custom_root = tmp_path / "out"
136
+ assert tg.get_test_dir(output_root=custom_root) == custom_root / "unit"
137
+
138
+
139
+ class TestDiscovery:
140
+ def test_find_untested_repo_scan(self, tmp_path):
141
+ (tmp_path / "pyproject.toml").write_text(
142
+ "[tool.pytest.ini_options]\n", encoding="utf-8"
143
+ )
144
+ (tmp_path / "AGENTS.md").write_text("# repo\n", encoding="utf-8")
145
+
146
+ scripts_dir = tmp_path / "scripts"
147
+ scripts_dir.mkdir()
148
+ (scripts_dir / "cleanup.py").write_text(
149
+ "def clean_default():\n return None\n", encoding="utf-8"
150
+ )
151
+
152
+ src_dir = tmp_path / "src"
153
+ src_dir.mkdir(parents=True)
154
+ (src_dir / "alpha.py").write_text("def x():\n return 1\n", encoding="utf-8")
155
+
156
+ tests_dir = tmp_path / "tests" / "backend" / "unit"
157
+ tests_dir.mkdir(parents=True)
158
+ (tests_dir / "test_cleanup.py").write_text("# existing\n", encoding="utf-8")
159
+
160
+ disc = gen.TestDiscovery(repo_root=tmp_path)
161
+ untested = disc.find_untested()
162
+
163
+ assert "scripts/cleanup.py" not in untested
164
+ assert "src/alpha.py" in untested
165
+
166
+ def test_find_untested_external(self, tmp_path):
167
+ src_dir = tmp_path / "external"
168
+ src_dir.mkdir()
169
+ (src_dir / "beta.py").write_text("x=1\n", encoding="utf-8")
170
+
171
+ disc = gen.TestDiscovery(repo_root=gen.ROOT)
172
+ untested = disc.find_untested(str(src_dir))
173
+ assert any("beta.py" in p for p in untested)
174
+
175
+ def test_find_untested_returns_empty_for_missing_path(self, tmp_path):
176
+ disc = gen.TestDiscovery(repo_root=tmp_path)
177
+ assert disc.find_untested(str(tmp_path / "does-not-exist")) == []
178
+
179
+ def test_iter_repo_source_files_for_non_repo_path(self, tmp_path):
180
+ src = tmp_path / "src"
181
+ src.mkdir()
182
+ f = src / "a.py"
183
+ f.write_text("x=1\n", encoding="utf-8")
184
+ disc = gen.TestDiscovery(repo_root=tmp_path / "other")
185
+ files = list(disc._iter_repo_source_files(src))
186
+ assert f.resolve() in files
187
+
188
+ def test_iter_python_files_file_mode_and_hidden_skip(self, tmp_path):
189
+ file_path = tmp_path / "alpha.py"
190
+ file_path.write_text("x=1\n", encoding="utf-8")
191
+ disc = gen.TestDiscovery(repo_root=tmp_path)
192
+ files = list(disc._iter_python_files(file_path))
193
+ assert files == [file_path.resolve()]
194
+ assert disc._should_skip_dir(tmp_path / ".hidden") is True
195
+ assert disc._is_candidate_source(tmp_path / "tests" / "x.py") is False
196
+
197
+
198
+ class TestMain:
199
+ def test_scan_mode(self, monkeypatch, capsys):
200
+ monkeypatch.setattr(sys, "argv", ["generate_tests.py", "--scan"])
201
+ gen.main()
202
+ out = capsys.readouterr().out
203
+ assert "Untested modules" in out or "All modules have tests" in out
204
+
205
+ def test_no_source_file_prints_help(self, monkeypatch):
206
+ monkeypatch.setattr(sys, "argv", ["generate_tests.py"])
207
+ with pytest.raises(SystemExit) as exc_info:
208
+ gen.main()
209
+ assert exc_info.value.code == 1
210
+
211
+ def test_missing_file_exits(self, monkeypatch, capsys):
212
+ monkeypatch.setattr(sys, "argv", ["generate_tests.py", "missing.py"])
213
+ with pytest.raises(SystemExit) as exc_info:
214
+ gen.main()
215
+ assert exc_info.value.code == 1
216
+ assert "File not found" in capsys.readouterr().out
217
+
218
+ def test_directory_source_exits(self, monkeypatch, tmp_path, capsys):
219
+ monkeypatch.setattr(sys, "argv", ["generate_tests.py", str(tmp_path)])
220
+ with pytest.raises(SystemExit) as exc_info:
221
+ gen.main()
222
+ assert exc_info.value.code == 1
223
+ assert "Source path is a directory" in capsys.readouterr().out
224
+
225
+ def test_dry_run_and_write(self, monkeypatch, tmp_path, capsys):
226
+ src = tmp_path / "mod.py"
227
+ src.write_text("def hello():\n return 1\n", encoding="utf-8")
228
+
229
+ monkeypatch.setattr(sys, "argv", ["generate_tests.py", str(src), "--dry-run"])
230
+ gen.main()
231
+ assert "Generated test template" in capsys.readouterr().out
232
+
233
+ out_root = tmp_path / "generated"
234
+ monkeypatch.setattr(
235
+ sys,
236
+ "argv",
237
+ ["generate_tests.py", str(src), "--output-root", str(out_root)],
238
+ )
239
+ gen.main()
240
+ # output-root puts files under <output-root>/<category>
241
+ assert (out_root / "unit" / "test_mod.py").exists()
242
+
243
+ def test_scan_mode_all_modules_have_tests_branch(self, monkeypatch, capsys):
244
+ class _Discovery:
245
+ def find_untested(self, _scan_target=None):
246
+ return []
247
+
248
+ monkeypatch.setattr(gen, "TestDiscovery", lambda: _Discovery())
249
+ monkeypatch.setattr(sys, "argv", ["generate_tests.py", "--scan"])
250
+ gen.main()
251
+ assert "All modules have tests" in capsys.readouterr().out
252
+
253
+ def test_no_entities_and_existing_file_branches(
254
+ self,
255
+ monkeypatch,
256
+ tmp_path,
257
+ capsys,
258
+ ):
259
+ src = tmp_path / "m.py"
260
+ src.write_text("x=1\n", encoding="utf-8")
261
+
262
+ class _Analyzer:
263
+ def __init__(self, *_a, **_k):
264
+ pass
265
+
266
+ def analyze(self):
267
+ return []
268
+
269
+ monkeypatch.setattr(gen, "CodeAnalyzer", _Analyzer)
270
+ monkeypatch.setattr(sys, "argv", ["generate_tests.py", str(src)])
271
+ gen.main()
272
+ assert "No testable entities found" in capsys.readouterr().out
273
+
274
+ class _Analyzer2:
275
+ def __init__(self, *_a, **_k):
276
+ pass
277
+
278
+ def analyze(self):
279
+ return [("hello", "function")]
280
+
281
+ monkeypatch.setattr(gen, "CodeAnalyzer", _Analyzer2)
282
+ out_root = tmp_path / "out"
283
+ out_root.mkdir()
284
+ existing = out_root / "unit" / "test_m.py"
285
+ existing.parent.mkdir(parents=True, exist_ok=True)
286
+ existing.write_text("# preexisting\n", encoding="utf-8")
287
+ monkeypatch.setattr(
288
+ sys,
289
+ "argv",
290
+ [
291
+ "generate_tests.py",
292
+ str(src),
293
+ "--output-root",
294
+ str(out_root),
295
+ ],
296
+ )
297
+ gen.main()
298
+ assert "File exists" in capsys.readouterr().out
299
+
300
+
301
+ def test_generate_tests_dunder_main(monkeypatch, tmp_path):
302
+ src = tmp_path / "sample_mod.py"
303
+ src.write_text("def hello():\n return 1\n", encoding="utf-8")
304
+ monkeypatch.setattr(sys, "argv", ["generate_tests.py", str(src), "--dry-run"])
305
+ runpy.run_path("scripts/generate_tests.py", run_name="__main__")
@@ -0,0 +1,357 @@
1
+ """Regression tests for collab git hook templates and installer overlay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import textwrap
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from tests.backend.unit.scripts._helpers import ROOT
15
+
16
+ GIT_SH_CANDIDATES = [
17
+ shutil.which("sh"),
18
+ r"C:\Users\kmartineztamayo\AppData\Local\Programs\Git\bin\sh.exe",
19
+ r"C:\Program Files\Git\bin\sh.exe",
20
+ ]
21
+
22
+
23
+ @pytest.fixture(scope="module")
24
+ def git_sh() -> str:
25
+ """Return a shell executable compatible with git hook scripts."""
26
+ for candidate in GIT_SH_CANDIDATES:
27
+ if candidate and Path(candidate).exists():
28
+ return candidate
29
+ pytest.skip("Git shell executable not available for hook template tests")
30
+
31
+
32
+ @pytest.fixture()
33
+ def hook_repo(tmp_path: Path) -> Path:
34
+ """Create a temporary git repo with minimal collab hook runtime."""
35
+ repo = tmp_path / "repo"
36
+ repo.mkdir()
37
+
38
+ subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
39
+ subprocess.run(
40
+ ["git", "config", "user.name", "Hook Tester"],
41
+ cwd=repo,
42
+ check=True,
43
+ capture_output=True,
44
+ )
45
+ subprocess.run(
46
+ ["git", "config", "user.email", "hook-tester@example.com"],
47
+ cwd=repo,
48
+ check=True,
49
+ capture_output=True,
50
+ )
51
+
52
+ (repo / "hooks").mkdir(parents=True)
53
+ (repo / ".venv" / "bin").mkdir(parents=True)
54
+ (repo / "scripts").mkdir(parents=True)
55
+ (repo / ".git" / "hooks").mkdir(parents=True, exist_ok=True)
56
+ (repo / ".pre-commit-config.yaml").write_text("repos: []\n", encoding="utf-8")
57
+
58
+ for hook_name in ("pre-commit", "post-commit", "pre-push", "commit-msg"):
59
+ source = ROOT / "hooks" / hook_name
60
+ target = repo / "hooks" / hook_name
61
+ target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
62
+
63
+ install_source = ROOT / "install_hooks.sh"
64
+ install_target = repo / "install_hooks.sh"
65
+ install_target.write_text(
66
+ install_source.read_text(encoding="utf-8"),
67
+ encoding="utf-8",
68
+ )
69
+
70
+ fake_python = repo / ".venv" / "bin" / "python"
71
+ # Use the absolute path of the running interpreter so that when the
72
+ # pre-push hook prepends .venv/bin to PATH, `python` never resolves
73
+ # back to this wrapper script causing an infinite self-referential loop.
74
+ real_python = sys.executable.replace("\\", "/")
75
+ fake_python.write_text(
76
+ textwrap.dedent(f"""
77
+ #!/bin/sh
78
+ exec "{real_python}" "$@"
79
+ """).lstrip(),
80
+ encoding="utf-8",
81
+ )
82
+ fake_python.chmod(0o755)
83
+
84
+ fake_pre_commit = repo / ".venv" / "bin" / "pre-commit"
85
+ fake_pre_commit.write_text(
86
+ textwrap.dedent("""
87
+ #!/bin/sh
88
+ stage=""
89
+ while [ $# -gt 0 ]; do
90
+ if [ "$1" = "--hook-stage" ]; then
91
+ stage="$2"
92
+ shift 2
93
+ continue
94
+ fi
95
+ shift
96
+ done
97
+ printf '[fake-pre-commit] %s\n' "$stage"
98
+ if [ "$stage" = "pre-push" ] && [ -n "$FAKE_PRE_PUSH_FAIL" ]; then
99
+ exit 1
100
+ fi
101
+ exit 0
102
+ """).lstrip(),
103
+ encoding="utf-8",
104
+ )
105
+ fake_pre_commit.chmod(0o755)
106
+
107
+ collab_hook_helper = repo / "scripts" / "collab_git_hook.py"
108
+ collab_hook_helper.write_text(
109
+ textwrap.dedent("""
110
+ import os
111
+ import sys
112
+
113
+ command = sys.argv[1]
114
+ if command == "acquire-staged":
115
+ mode = os.getenv("FAKE_ACQUIRE_MODE", "watcher")
116
+ if mode == "watcher":
117
+ message = (
118
+ "[collab] Watcher running (PID: 3500) "
119
+ "— skipping pre-commit lock acquisition."
120
+ )
121
+ print(
122
+ message,
123
+ file=sys.stderr,
124
+ )
125
+ raise SystemExit(0)
126
+ if mode == "conflict":
127
+ print(
128
+ "[collab] Commit blocked due to lock conflicts:",
129
+ file=sys.stderr,
130
+ )
131
+ print(" - conflicted.txt (locked by @otherdev)", file=sys.stderr)
132
+ raise SystemExit(1)
133
+ print("[collab] Locks acquired for 1 staged file(s).", file=sys.stderr)
134
+ raise SystemExit(0)
135
+ if command == "release-all":
136
+ count = os.getenv("FAKE_RELEASE_COUNT", "1")
137
+ print(f"[collab] Released {count} lock(s).", file=sys.stderr)
138
+ raise SystemExit(0)
139
+ raise SystemExit(2)
140
+ """).lstrip(),
141
+ encoding="utf-8",
142
+ )
143
+
144
+ return repo
145
+
146
+
147
+ def _run_sh(
148
+ script: Path,
149
+ shell_path: str,
150
+ cwd: Path,
151
+ env: dict[str, str] | None = None,
152
+ ) -> subprocess.CompletedProcess[str]:
153
+ run_env = os.environ.copy()
154
+ if env:
155
+ run_env.update(env)
156
+ return subprocess.run(
157
+ [shell_path, str(script)],
158
+ cwd=cwd,
159
+ env=run_env,
160
+ stdin=subprocess.DEVNULL,
161
+ capture_output=True,
162
+ text=True,
163
+ check=False,
164
+ )
165
+
166
+
167
+ def _normalized_output(result: subprocess.CompletedProcess[str]) -> str:
168
+ """Normalize common Git Bash mojibake so assertions stay stable on Windows."""
169
+ return (result.stdout + result.stderr).replace("—", "—")
170
+
171
+
172
+ def test_install_hooks_copies_templates_into_git_hooks(hook_repo: Path, git_sh: str):
173
+ result = _run_sh(hook_repo / "install_hooks.sh", git_sh, hook_repo)
174
+
175
+ assert result.returncode == 0
176
+ assert "Installed git hooks from hooks/" in result.stdout
177
+ for hook_name in ("pre-commit", "post-commit", "pre-push", "commit-msg"):
178
+ expected = (hook_repo / "hooks" / hook_name).read_text(encoding="utf-8")
179
+ actual = (hook_repo / ".git" / "hooks" / hook_name).read_text(encoding="utf-8")
180
+ assert actual == expected
181
+
182
+
183
+ def test_pre_commit_hook_prints_watcher_message_then_runs_framework(
184
+ hook_repo: Path,
185
+ git_sh: str,
186
+ ):
187
+ staged = hook_repo / "tracked.txt"
188
+ staged.write_text("content\n", encoding="utf-8")
189
+ subprocess.run(["git", "add", "tracked.txt"], cwd=hook_repo, check=True)
190
+
191
+ result = _run_sh(
192
+ hook_repo / "hooks" / "pre-commit",
193
+ git_sh,
194
+ hook_repo,
195
+ env={"SKIP": "validate-code"},
196
+ )
197
+
198
+ assert result.returncode == 0
199
+ combined = _normalized_output(result)
200
+ assert (
201
+ "[collab] Watcher running (PID: 3500) — skipping pre-commit lock acquisition."
202
+ in combined
203
+ )
204
+ assert "[fake-pre-commit] pre-commit" in combined
205
+
206
+
207
+ def test_pre_commit_hook_blocks_on_conflict(hook_repo: Path, git_sh: str):
208
+ staged = hook_repo / "conflicted.txt"
209
+ staged.write_text("content\n", encoding="utf-8")
210
+ subprocess.run(["git", "add", "conflicted.txt"], cwd=hook_repo, check=True)
211
+
212
+ result = _run_sh(
213
+ hook_repo / "hooks" / "pre-commit",
214
+ git_sh,
215
+ hook_repo,
216
+ env={"FAKE_ACQUIRE_MODE": "conflict"},
217
+ )
218
+
219
+ assert result.returncode == 1
220
+ combined = _normalized_output(result)
221
+ assert "[collab] Commit blocked due to lock conflicts:" in combined
222
+ assert "conflicted.txt" in combined
223
+ assert "[collab] Commit aborted due to file lock conflicts." in combined
224
+ assert "[fake-pre-commit] pre-commit" not in combined
225
+
226
+
227
+ def test_post_commit_hook_prints_message_and_chains_framework(
228
+ hook_repo: Path,
229
+ git_sh: str,
230
+ ):
231
+ result = _run_sh(
232
+ hook_repo / "hooks" / "post-commit",
233
+ git_sh,
234
+ hook_repo,
235
+ )
236
+
237
+ assert result.returncode == 0
238
+ combined = _normalized_output(result)
239
+ assert (
240
+ "[collab] Commit detected. Locks remain active until files are pushed."
241
+ in combined
242
+ )
243
+ assert "[fake-pre-commit] post-commit" in combined
244
+
245
+
246
+ def test_pre_push_hook_keeps_locks_when_validation_fails(hook_repo: Path, git_sh: str):
247
+ result = _run_sh(
248
+ hook_repo / "hooks" / "pre-push",
249
+ git_sh,
250
+ hook_repo,
251
+ env={"FAKE_PRE_PUSH_FAIL": "1"},
252
+ )
253
+
254
+ assert result.returncode == 1
255
+ combined = _normalized_output(result)
256
+ assert "[fake-pre-commit] pre-push" in combined
257
+ assert "[collab] Pre-push validation failed - keeping locks active." in combined
258
+ assert (
259
+ "[collab] Releasing all locks after successful pre-push validation..."
260
+ not in combined
261
+ )
262
+
263
+
264
+ def test_pre_push_hook_releases_locks_after_success(hook_repo: Path, git_sh: str):
265
+ result = _run_sh(
266
+ hook_repo / "hooks" / "pre-push",
267
+ git_sh,
268
+ hook_repo,
269
+ env={"FAKE_RELEASE_COUNT": "4"},
270
+ )
271
+
272
+ assert result.returncode == 0
273
+ combined = _normalized_output(result)
274
+ assert "[fake-pre-commit] pre-push" in combined
275
+ assert (
276
+ "[collab] Releasing all locks after successful pre-push validation..."
277
+ in combined
278
+ )
279
+ assert "[collab] Released 4 lock(s)." in combined
280
+
281
+
282
+ def _run_sh_with_arg(
283
+ script: Path,
284
+ shell_path: str,
285
+ cwd: Path,
286
+ arg: str,
287
+ env: dict[str, str] | None = None,
288
+ ) -> subprocess.CompletedProcess[str]:
289
+ run_env = os.environ.copy()
290
+ if env:
291
+ run_env.update(env)
292
+ return subprocess.run(
293
+ [shell_path, str(script), arg],
294
+ cwd=cwd,
295
+ env=run_env,
296
+ stdin=subprocess.DEVNULL,
297
+ capture_output=True,
298
+ text=True,
299
+ check=False,
300
+ )
301
+
302
+
303
+ def test_commit_msg_hook_passes_valid_conventional_commit(hook_repo: Path, git_sh: str):
304
+ msg_file = hook_repo / ".git" / "COMMIT_EDITMSG"
305
+ msg_file.write_text("feat(core): add new feature\n", encoding="utf-8")
306
+
307
+ result = _run_sh_with_arg(
308
+ hook_repo / "hooks" / "commit-msg",
309
+ git_sh,
310
+ hook_repo,
311
+ arg=str(msg_file),
312
+ )
313
+
314
+ assert result.returncode == 0
315
+
316
+
317
+ def test_commit_msg_hook_blocks_invalid_message(hook_repo: Path, git_sh: str):
318
+ msg_file = hook_repo / ".git" / "COMMIT_EDITMSG"
319
+ msg_file.write_text("added some stuff\n", encoding="utf-8")
320
+
321
+ result = _run_sh_with_arg(
322
+ hook_repo / "hooks" / "commit-msg",
323
+ git_sh,
324
+ hook_repo,
325
+ arg=str(msg_file),
326
+ )
327
+
328
+ assert result.returncode == 1
329
+ assert "Conventional Commits" in result.stderr
330
+
331
+
332
+ def test_commit_msg_hook_allows_merge_commit(hook_repo: Path, git_sh: str):
333
+ msg_file = hook_repo / ".git" / "COMMIT_EDITMSG"
334
+ msg_file.write_text("Merge branch 'main' into feature\n", encoding="utf-8")
335
+
336
+ result = _run_sh_with_arg(
337
+ hook_repo / "hooks" / "commit-msg",
338
+ git_sh,
339
+ hook_repo,
340
+ arg=str(msg_file),
341
+ )
342
+
343
+ assert result.returncode == 0
344
+
345
+
346
+ def test_commit_msg_hook_allows_fixup_commit(hook_repo: Path, git_sh: str):
347
+ msg_file = hook_repo / ".git" / "COMMIT_EDITMSG"
348
+ msg_file.write_text("fixup! feat(core): add new feature\n", encoding="utf-8")
349
+
350
+ result = _run_sh_with_arg(
351
+ hook_repo / "hooks" / "commit-msg",
352
+ git_sh,
353
+ hook_repo,
354
+ arg=str(msg_file),
355
+ )
356
+
357
+ assert result.returncode == 0