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
scripts/cleanup.py ADDED
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env python3
2
+ """Artifact Cleanup Script.
3
+
4
+ Removes generated files and directories produced by testing, linting,
5
+ and formatting tools to keep the repository clean between runs.
6
+
7
+ Can be run standalone or imported and called from other scripts.
8
+
9
+ Usage:
10
+ python scripts/cleanup.py # Clean test artifacts + coverage (default)
11
+ python scripts/cleanup.py --all # Clean everything including caches
12
+ python scripts/cleanup.py --coverage # Only coverage reporting
13
+ # (.coverage, htmlcov/, coverage/)
14
+ python scripts/cleanup.py --tests # Only remove test runner output
15
+ python scripts/cleanup.py --packaging # Only remove packaging outputs
16
+ python scripts/cleanup.py --caches # Only tool caches (pycache, mypy, ruff)
17
+ python scripts/cleanup.py --dry-run # Show what would be deleted without deleting
18
+ python scripts/cleanup.py --packaging --yes
19
+ # Non-interactive/CI removal
20
+ """
21
+
22
+ import argparse
23
+ import shutil
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import List, Tuple
27
+
28
+ ROOT = Path(__file__).parent.parent
29
+
30
+ # Directories/files that should NEVER be cleaned regardless of flags
31
+ PROTECTED = {".venv", "venv", "node_modules", ".git", "instance", "test_data"}
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Artifact groups
35
+ # ---------------------------------------------------------------------------
36
+
37
+ # Coverage reporting and data files generated by pytest-cov
38
+ COVERAGE_ARTIFACTS: List[str] = [
39
+ "htmlcov", # pytest HTML coverage report (--cov-report=html)
40
+ "coverage", # JS/Jest coverage directory (coverage/lcov.info, etc.)
41
+ ".coverage", # pytest coverage data file
42
+ "coverage.xml", # pytest XML coverage (--cov-report=xml)
43
+ "lcov.info", # LCOV coverage format
44
+ ]
45
+
46
+ # Glob patterns for scattered coverage files (e.g. parallel pytest runs)
47
+ COVERAGE_GLOB_PATTERNS: List[str] = [
48
+ ".coverage.*", # Parallel coverage data: .coverage.hostname.pid
49
+ ]
50
+
51
+ # Test output artifacts
52
+ TEST_OUTPUT_ARTIFACTS: List[str] = [
53
+ "test-results", # Test traces/screenshots on failure
54
+ "playwright-report", # Playwright HTML report
55
+ "blob-report", # Playwright blob reporter output
56
+ "jscpd-report", # JSCPD duplicate code report
57
+ ".jscpd", # JSCPD internal cache
58
+ ]
59
+
60
+ # Packaging-specific outputs (separate from test artifacts)
61
+ PACKAGING_ARTIFACTS: List[str] = [
62
+ ".venv.verify", # ephemeral venv used by packaging smoke tests
63
+ "dist", # wheel and sdist output
64
+ "build", # build workspace used during packaging
65
+ "pip-wheel-metadata", # pip wheel metadata dir
66
+ ]
67
+
68
+ # Glob patterns for scattered test output files
69
+ TEST_OUTPUT_GLOB_PATTERNS: List[str] = [
70
+ "pytest-cache-files-*", # Randomly named temporary directories from test runners
71
+ ]
72
+
73
+ # Formatting tool artifacts
74
+ FORMATTING_GLOB_PATTERNS: List[str] = [
75
+ "**/*.isorted", # isort backup files created during formatting (recursive)
76
+ ]
77
+
78
+ # Packaging-specific glob patterns
79
+ PACKAGING_GLOB_PATTERNS: List[str] = [
80
+ "*.egg-info", # packaging metadata directories
81
+ ]
82
+
83
+ # Tool caches that speed up subsequent runs but pollute the repo visually.
84
+ # These are optional (--caches / --all) since cleaning them slows next run.
85
+ CACHE_ARTIFACTS: List[str] = [
86
+ ".pytest_cache", # Pytest test cache
87
+ ".mypy_cache", # Mypy type-check cache
88
+ ".ruff_cache", # Ruff lint cache
89
+ ".bandit_cache", # Bandit security scan cache
90
+ ]
91
+
92
+ # Glob patterns for scattered cache files
93
+ CACHE_GLOB_PATTERNS: List[str] = [
94
+ "**/__pycache__", # Python bytecode cache (scattered everywhere)
95
+ "**/*.pyc", # Compiled Python bytecode files
96
+ "**/*.pyo", # Optimised Python bytecode files
97
+ ]
98
+
99
+
100
+ def _is_protected(path: Path) -> bool:
101
+ """Return True if any part of the path is in the protected set."""
102
+ return any(part in PROTECTED for part in path.parts)
103
+
104
+
105
+ def _remove(path: Path, dry_run: bool) -> Tuple[bool, str]:
106
+ """Delete a file or directory tree.
107
+
108
+ Args:
109
+ path: Path to remove.
110
+ dry_run: If True, only report what would be removed.
111
+
112
+ Returns:
113
+ Tuple of (success, message).
114
+ """
115
+ if not path.exists():
116
+ return False, ""
117
+
118
+ kind = "dir" if path.is_dir() else "file"
119
+ label = f" 🗑 [{kind}] {path.relative_to(ROOT)}"
120
+
121
+ if dry_run:
122
+ return True, f"[DRY-RUN] {label}"
123
+
124
+ try:
125
+ if path.is_dir():
126
+ shutil.rmtree(path)
127
+ else:
128
+ path.unlink()
129
+ return True, label
130
+ except PermissionError as exc:
131
+ return False, f" ⚠ Could not remove {path}: {exc}"
132
+ except OSError as exc:
133
+ return False, f" ⚠ Could not remove {path}: {exc}"
134
+
135
+
136
+ def _clean_items(names: List[str], dry_run: bool) -> int:
137
+ """Remove top-level artifacts by exact name relative to ROOT.
138
+
139
+ Args:
140
+ names: List of names to look for directly under ROOT.
141
+ dry_run: If True, only report.
142
+
143
+ Returns:
144
+ Number of items removed (or would-be removed).
145
+ """
146
+ count = 0
147
+ for name in names:
148
+ path = ROOT / name
149
+ if path.exists():
150
+ ok, msg = _remove(path, dry_run)
151
+ if msg:
152
+ print(msg)
153
+ if ok:
154
+ count += 1
155
+ return count
156
+
157
+
158
+ def _clean_glob(patterns: List[str], dry_run: bool) -> int:
159
+ """Remove files/dirs matching glob patterns anywhere under ROOT.
160
+
161
+ Skips protected directories and .venv paths automatically.
162
+
163
+ Args:
164
+ patterns: List of glob patterns relative to ROOT.
165
+ dry_run: If True, only report.
166
+
167
+ Returns:
168
+ Number of items removed (or would-be removed).
169
+ """
170
+ count = 0
171
+ seen: set = set()
172
+
173
+ for pattern in patterns:
174
+ for match in sorted(ROOT.glob(pattern)):
175
+ if match in seen:
176
+ continue
177
+ seen.add(match)
178
+
179
+ try:
180
+ if _is_protected(match.relative_to(ROOT)):
181
+ continue
182
+ except ValueError:
183
+ continue
184
+
185
+ ok, msg = _remove(match, dry_run)
186
+ if msg:
187
+ print(msg)
188
+ if ok:
189
+ count += 1
190
+
191
+ return count
192
+
193
+
194
+ def clean_coverage(dry_run: bool = False) -> int:
195
+ """Remove coverage reporting and data files.
196
+
197
+ Args:
198
+ dry_run: If True, only report what would be removed.
199
+
200
+ Returns:
201
+ Total number of items cleaned.
202
+ """
203
+ print("Cleaning coverage artifacts...")
204
+ total = 0
205
+ total += _clean_items(COVERAGE_ARTIFACTS, dry_run)
206
+ total += _clean_glob(COVERAGE_GLOB_PATTERNS, dry_run)
207
+ return total
208
+
209
+
210
+ def clean_test_output(dry_run: bool = False) -> int:
211
+ """Remove test runner output artifacts.
212
+
213
+ Args:
214
+ dry_run: If True, only report what would be removed.
215
+
216
+ Returns:
217
+ Total number of items cleaned.
218
+ """
219
+ print("Cleaning test output...")
220
+ total = 0
221
+ total += _clean_items(TEST_OUTPUT_ARTIFACTS, dry_run)
222
+ total += _clean_glob(TEST_OUTPUT_GLOB_PATTERNS, dry_run)
223
+ return total
224
+
225
+
226
+ def clean_caches(dry_run: bool = False) -> int:
227
+ """Remove tool caches (__pycache__, .mypy_cache, .ruff_cache, etc.).
228
+
229
+ Note: Removing these makes the next tool run slightly slower because
230
+ caches must be rebuilt.
231
+
232
+ Args:
233
+ dry_run: If True, only report what would be removed.
234
+
235
+ Returns:
236
+ Total number of items cleaned.
237
+ """
238
+ print("Cleaning tool caches...")
239
+ total = 0
240
+ total += _clean_items(CACHE_ARTIFACTS, dry_run)
241
+ total += _clean_glob(CACHE_GLOB_PATTERNS, dry_run)
242
+ return total
243
+
244
+
245
+ def clean_default(dry_run: bool = False) -> int:
246
+ """Remove test artifacts, coverage reporting, and formatting artifacts (default
247
+ behaviour).
248
+
249
+ Keeps tool caches (.pytest_cache, .mypy_cache, etc.) intact so
250
+ subsequent runs stay fast.
251
+
252
+ Args:
253
+ dry_run: If True, only report what would be removed.
254
+
255
+ Returns:
256
+ Total number of items cleaned.
257
+ """
258
+ return (
259
+ clean_coverage(dry_run) + clean_test_output(dry_run) + clean_formatting(dry_run)
260
+ )
261
+
262
+
263
+ def clean_all(dry_run: bool = False) -> int:
264
+ """Remove everything: coverage, test output, formatting artifacts, AND tool caches.
265
+
266
+ Args:
267
+ dry_run: If True, only report what would be removed.
268
+
269
+ Returns:
270
+ Total number of items cleaned.
271
+ """
272
+ return clean_default(dry_run) + clean_caches(dry_run)
273
+
274
+
275
+ def clean_formatting(dry_run: bool = False) -> int:
276
+ """Remove formatting tool artifacts (*.isorted files from isort).
277
+
278
+ Args:
279
+ dry_run: If True, only report what would be removed.
280
+
281
+ Returns:
282
+ Total number of items cleaned.
283
+ """
284
+ print("Cleaning formatting artifacts...")
285
+ return _clean_glob(FORMATTING_GLOB_PATTERNS, dry_run)
286
+
287
+
288
+ def clean_packaging(dry_run: bool = False) -> int:
289
+ """Remove packaging build outputs: dist/, build/, wheel metadata, and egg-info dirs.
290
+
291
+ Args:
292
+ dry_run: If True, only report what would be removed.
293
+
294
+ Returns:
295
+ Total number of items cleaned.
296
+ """
297
+ print("Cleaning packaging artifacts...")
298
+ total = 0
299
+ total += _clean_items(PACKAGING_ARTIFACTS, dry_run)
300
+ total += _clean_glob(PACKAGING_GLOB_PATTERNS, dry_run)
301
+ return total
302
+
303
+
304
+ def main() -> int:
305
+ """Entry point for standalone execution."""
306
+ parser = argparse.ArgumentParser(
307
+ description=(
308
+ "Remove generated artifacts from testing, linting, and formatting tools."
309
+ )
310
+ )
311
+ group = parser.add_mutually_exclusive_group()
312
+ group.add_argument(
313
+ "--all",
314
+ action="store_true",
315
+ help="Clean everything including tool caches (__pycache__, .mypy_cache, etc.)",
316
+ )
317
+ group.add_argument(
318
+ "--coverage",
319
+ action="store_true",
320
+ help="Only remove coverage reporting and data files",
321
+ )
322
+ group.add_argument(
323
+ "--tests",
324
+ action="store_true",
325
+ help="Only remove test runner output",
326
+ )
327
+ group.add_argument(
328
+ "--packaging",
329
+ action="store_true",
330
+ help="Remove packaging artifacts (dist, build, wheel metadata)",
331
+ )
332
+ group.add_argument(
333
+ "--caches",
334
+ action="store_true",
335
+ help="Only remove tool caches (__pycache__, .mypy_cache, .ruff_cache, etc.)",
336
+ )
337
+ parser.add_argument(
338
+ "--dry-run",
339
+ action="store_true",
340
+ help="Show what would be deleted without actually deleting anything",
341
+ )
342
+ parser.add_argument(
343
+ "--yes",
344
+ action="store_true",
345
+ help="Assume yes to prompts (non-interactive/CI friendly)",
346
+ )
347
+
348
+ args = parser.parse_args()
349
+
350
+ print("\n" + "=" * 60)
351
+ mode = "[DRY-RUN] " if args.dry_run else ""
352
+ print(f"{mode}🧹 ARTIFACT CLEANUP")
353
+ print("=" * 60)
354
+
355
+ if args.all:
356
+ count = clean_all(args.dry_run)
357
+ label = "all artifacts (including caches)"
358
+ elif args.coverage:
359
+ count = clean_coverage(args.dry_run)
360
+ label = "coverage artifacts"
361
+ elif args.tests:
362
+ count = clean_test_output(args.dry_run)
363
+ label = "test output artifacts"
364
+ elif args.packaging:
365
+ # Require confirmation for packaging removal (unless --dry-run/--yes)
366
+ if not args.dry_run and not args.yes:
367
+ reply = (
368
+ input("Remove packaging outputs (dist/, build/, egg-info)? [y/N]: ")
369
+ .strip()
370
+ .lower()
371
+ )
372
+ if reply not in ("y", "yes"):
373
+ print("Aborted by user.")
374
+ return 2
375
+ count = clean_packaging(args.dry_run)
376
+ label = "packaging artifacts"
377
+ elif args.caches:
378
+ count = clean_caches(args.dry_run)
379
+ label = "tool caches"
380
+ else:
381
+ count = clean_default(args.dry_run)
382
+ label = "test artifacts + coverage"
383
+
384
+ print()
385
+ if count:
386
+ verb = "Would remove" if args.dry_run else "Removed"
387
+ print(f"✅ {verb} {count} item(s) from {label}.")
388
+ else:
389
+ print(f"✨ Nothing to clean in {label}.")
390
+
391
+ return 0
392
+
393
+
394
+ if __name__ == "__main__":
395
+ sys.exit(main())
@@ -0,0 +1,190 @@
1
+ """Git hook helpers for collab lock lifecycle integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from dotenv import load_dotenv
13
+
14
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
15
+ SRC_ROOT = PROJECT_ROOT / "src"
16
+
17
+ if str(SRC_ROOT) not in sys.path:
18
+ sys.path.insert(0, str(SRC_ROOT))
19
+
20
+ load_dotenv(PROJECT_ROOT / ".env")
21
+
22
+
23
+ def _git_output(*args: str) -> str:
24
+ result = subprocess.run(
25
+ ["git", *args],
26
+ cwd=PROJECT_ROOT,
27
+ capture_output=True,
28
+ text=True,
29
+ check=False,
30
+ )
31
+ if result.returncode != 0:
32
+ raise RuntimeError(
33
+ result.stderr.strip() or result.stdout.strip() or "git failed"
34
+ )
35
+ return result.stdout.strip()
36
+
37
+
38
+ def _get_staged_files() -> list[str]:
39
+ output = _git_output("diff", "--cached", "--name-only", "--diff-filter=ACMR")
40
+ return [line.strip() for line in output.splitlines() if line.strip()]
41
+
42
+
43
+ def _read_pid_file() -> Optional[int]:
44
+ from src.lock_client import PID_FILE
45
+
46
+ pid_path = Path(PID_FILE)
47
+ if not pid_path.exists():
48
+ return None
49
+
50
+ try:
51
+ raw = pid_path.read_text(encoding="utf-8").strip()
52
+ except OSError:
53
+ return None
54
+
55
+ if not raw:
56
+ return None
57
+
58
+ if raw.startswith("{"):
59
+ try:
60
+ payload = json.loads(raw)
61
+ except json.JSONDecodeError:
62
+ return None
63
+ pid = payload.get("pid")
64
+ return pid if isinstance(pid, int) else None
65
+
66
+ try:
67
+ return int(raw)
68
+ except ValueError:
69
+ return None
70
+
71
+
72
+ def _pid_is_running(pid: int) -> bool:
73
+ try:
74
+ import psutil # type: ignore
75
+
76
+ return bool(psutil.pid_exists(pid))
77
+ except Exception:
78
+ try:
79
+ os.kill(pid, 0)
80
+ except PermissionError:
81
+ return True
82
+ except OSError:
83
+ return False
84
+ return True
85
+
86
+
87
+ def _watcher_pid() -> Optional[int]:
88
+ pid = _read_pid_file()
89
+ if pid is None:
90
+ return None
91
+ return pid if _pid_is_running(pid) else None
92
+
93
+
94
+ def acquire_staged() -> int:
95
+ from src.lock_client import LockClient
96
+
97
+ staged_files = _get_staged_files()
98
+ if not staged_files:
99
+ return 0
100
+
101
+ watcher_pid = _watcher_pid()
102
+ if watcher_pid is not None:
103
+ print(
104
+ "[collab] Watcher running "
105
+ f"(PID: {watcher_pid}) — skipping pre-commit lock acquisition.",
106
+ file=sys.stderr,
107
+ )
108
+ return 0
109
+
110
+ try:
111
+ client = LockClient()
112
+ ok, failed, _message = client.acquire_multiple(
113
+ staged_files, reason="pre-commit"
114
+ )
115
+ except Exception as exc:
116
+ print(f"[collab] Warning: lock check failed: {exc}", file=sys.stderr)
117
+ return 1 if os.getenv("LOCK_STRICT", "0") == "1" else 0
118
+
119
+ if ok:
120
+ print(
121
+ f"[collab] Locks acquired for {len(staged_files)} staged file(s).",
122
+ file=sys.stderr,
123
+ )
124
+ return 0
125
+
126
+ print("[collab] Commit blocked due to lock conflicts:", file=sys.stderr)
127
+ for file_path in failed:
128
+ try:
129
+ status = client.get_lock_status(file_path)
130
+ except Exception:
131
+ status = {}
132
+ owner = status.get("locked_by") or status.get("developer_id") or "unknown"
133
+ print(f" - {file_path} (locked by @{owner})", file=sys.stderr)
134
+ return 1
135
+
136
+
137
+ def release_all() -> int:
138
+ from src.lock_client import LockClient
139
+
140
+ try:
141
+ client = LockClient()
142
+ released = client.release_all()
143
+ except Exception as exc:
144
+ print(f"[collab] Warning: lock cleanup failed: {exc}", file=sys.stderr)
145
+ return 0
146
+
147
+ print(f"[collab] Released {released} lock(s).", file=sys.stderr)
148
+ return 0
149
+
150
+
151
+ def validate_and_release() -> int:
152
+ """Run quick validation, then release locks only on success."""
153
+ validate_script = PROJECT_ROOT / "scripts" / "validate_code.py"
154
+ result = subprocess.run(
155
+ [sys.executable, str(validate_script), "--quick"],
156
+ cwd=PROJECT_ROOT,
157
+ stdout=subprocess.DEVNULL,
158
+ check=False,
159
+ )
160
+ if result.returncode != 0:
161
+ print(
162
+ "[collab] Pre-push validation failed — keeping locks active.",
163
+ file=sys.stderr,
164
+ )
165
+ return result.returncode
166
+ return release_all()
167
+
168
+
169
+ def main() -> int:
170
+ if len(sys.argv) != 2:
171
+ print(
172
+ "Usage: python scripts/collab_git_hook.py "
173
+ "<acquire-staged|release-all|validate-and-release>"
174
+ )
175
+ return 2
176
+
177
+ command = sys.argv[1]
178
+ if command == "acquire-staged":
179
+ return acquire_staged()
180
+ if command == "release-all":
181
+ return release_all()
182
+ if command == "validate-and-release":
183
+ return validate_and_release()
184
+
185
+ print(f"Unknown command: {command}", file=sys.stderr)
186
+ return 2
187
+
188
+
189
+ if __name__ == "__main__":
190
+ raise SystemExit(main())