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.
- collab/__init__.py +77 -0
- collab/__main__.py +11 -0
- collab_runtime-0.2.9.dist-info/METADATA +218 -0
- collab_runtime-0.2.9.dist-info/RECORD +82 -0
- collab_runtime-0.2.9.dist-info/WHEEL +5 -0
- collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
- collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
- collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
- scripts/cleanup.py +395 -0
- scripts/collab_git_hook.py +190 -0
- scripts/format_code.py +594 -0
- scripts/generate_tests.py +560 -0
- scripts/validate_code.py +1397 -0
- src/__init__.py +4 -0
- src/dashboard/index.html +1131 -0
- src/live_locks_watcher.py +1982 -0
- src/lock_client.py +4268 -0
- src/logging_config.py +259 -0
- src/main.py +436 -0
- tests/backend/__init__.py +0 -0
- tests/backend/functional/__init__.py +0 -0
- tests/backend/functional/test_package_imports.py +43 -0
- tests/backend/integration/__init__.py +0 -0
- tests/backend/integration/test_cli_contract_parity.py +220 -0
- tests/backend/performance/__init__.py +0 -0
- tests/backend/reliability/__init__.py +0 -0
- tests/backend/security/__init__.py +0 -0
- tests/backend/unit/live_locks_watcher/__init__.py +5 -0
- tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
- tests/backend/unit/live_locks_watcher/conftest.py +18 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
- tests/backend/unit/lock_client/__init__.py +1 -0
- tests/backend/unit/lock_client/_helpers.py +132 -0
- tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
- tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
- tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
- tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
- tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
- tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
- tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
- tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
- tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
- tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
- tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
- tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
- tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
- tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
- tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
- tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
- tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
- tests/backend/unit/scripts/__init__.py +1 -0
- tests/backend/unit/scripts/_helpers.py +42 -0
- tests/backend/unit/scripts/test_cleanup.py +285 -0
- tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
- tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
- tests/backend/unit/scripts/test_format_code.py +368 -0
- tests/backend/unit/scripts/test_format_code_ported.py +177 -0
- tests/backend/unit/scripts/test_generate_tests.py +305 -0
- tests/backend/unit/scripts/test_hook_templates.py +357 -0
- tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
- tests/backend/unit/scripts/test_validate_code.py +867 -0
- tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
- tests/backend/unit/test_entrypoints_main_run.py +83 -0
- tests/backend/unit/test_logging_config.py +529 -0
- tests/backend/unit/test_main_watch_pid_file.py +278 -0
- tests/conftest.py +167 -0
- tests/frontend/__init__.py +0 -0
- tests/frontend/jest/__init__.py +0 -0
- tests/frontend/playwright/__init__.py +0 -0
- 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())
|