pintest-cli 0.2.0__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.
- pintest/__init__.py +14 -0
- pintest/build_mapping_iterative.py +339 -0
- pintest/cli.py +681 -0
- pintest/cloud_mapping_db.py +218 -0
- pintest/config.py +102 -0
- pintest/coverage_mapper.py +356 -0
- pintest/git_diff_parser.py +232 -0
- pintest/post_commit_hook.py +78 -0
- pintest/pre_commit_hook.py +1472 -0
- pintest/range_set.py +173 -0
- pintest/test_mapping_db_v2.py +381 -0
- pintest/update_mapping.py +130 -0
- pintest_cli-0.2.0.dist-info/METADATA +527 -0
- pintest_cli-0.2.0.dist-info/RECORD +21 -0
- pintest_cli-0.2.0.dist-info/WHEEL +5 -0
- pintest_cli-0.2.0.dist-info/entry_points.txt +2 -0
- pintest_cli-0.2.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_git_diff_parser.py +60 -0
- tests/test_new_feature.py +1 -0
- tests/test_range_set.py +261 -0
|
@@ -0,0 +1,1472 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Pre-commit hook for Pintest.
|
|
4
|
+
|
|
5
|
+
Runs only tests affected by code changes, with coverage enabled.
|
|
6
|
+
Combines new coverage with existing .coverage file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Set, Optional
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
# Add pintest to path
|
|
18
|
+
SCRIPT_DIR = Path(__file__).parent
|
|
19
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
20
|
+
|
|
21
|
+
from pintest.git_diff_parser import GitDiffParser
|
|
22
|
+
from pintest.test_mapping_db_v2 import TestMappingDBV2
|
|
23
|
+
from pintest.config import Config
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TeeOutput:
|
|
27
|
+
"""Write output to both stdout and a log file."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, log_file: Path):
|
|
30
|
+
self.log_file = log_file
|
|
31
|
+
self.stdout = sys.stdout
|
|
32
|
+
|
|
33
|
+
def __enter__(self):
|
|
34
|
+
# Open log file in append mode
|
|
35
|
+
self.file = open(self.log_file, 'a', encoding='utf-8')
|
|
36
|
+
|
|
37
|
+
# Write session header
|
|
38
|
+
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
39
|
+
header = f"\n{'='*80}\nPre-commit run: {timestamp}\n{'='*80}\n"
|
|
40
|
+
self.file.write(header)
|
|
41
|
+
self.file.flush()
|
|
42
|
+
|
|
43
|
+
# Replace sys.stdout
|
|
44
|
+
sys.stdout = self
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, *args):
|
|
48
|
+
# Restore original stdout
|
|
49
|
+
sys.stdout = self.stdout
|
|
50
|
+
|
|
51
|
+
# Write footer and close file
|
|
52
|
+
footer = f"{'='*80}\n"
|
|
53
|
+
self.file.write(footer)
|
|
54
|
+
self.file.flush()
|
|
55
|
+
self.file.close()
|
|
56
|
+
|
|
57
|
+
def write(self, text):
|
|
58
|
+
"""Write to both stdout and log file."""
|
|
59
|
+
self.stdout.write(text)
|
|
60
|
+
self.stdout.flush()
|
|
61
|
+
self.file.write(text)
|
|
62
|
+
self.file.flush()
|
|
63
|
+
|
|
64
|
+
def flush(self):
|
|
65
|
+
"""Flush both outputs."""
|
|
66
|
+
self.stdout.flush()
|
|
67
|
+
self.file.flush()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def ensure_git_lfs(repo_root: Path, verbose: bool = False) -> bool:
|
|
71
|
+
"""
|
|
72
|
+
Ensure Git LFS is initialized in the repository.
|
|
73
|
+
|
|
74
|
+
This is needed if the repo uses Git LFS for storing large files like .test_mapping.db.
|
|
75
|
+
Safe to run multiple times.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
repo_root: Repository root directory
|
|
79
|
+
verbose: Print verbose output
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if Git LFS is available and initialized, False otherwise
|
|
83
|
+
"""
|
|
84
|
+
# Check if git-lfs is installed
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
["git", "lfs", "version"],
|
|
87
|
+
cwd=repo_root,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if result.returncode != 0:
|
|
93
|
+
if verbose:
|
|
94
|
+
print("ℹ️ Git LFS not installed (optional - only needed for pre-built databases)")
|
|
95
|
+
return True # Not a failure - just not available
|
|
96
|
+
|
|
97
|
+
if verbose:
|
|
98
|
+
print("🔧 Initializing Git LFS...", flush=True)
|
|
99
|
+
|
|
100
|
+
# Initialize Git LFS (safe to run multiple times)
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
["git", "lfs", "install"],
|
|
103
|
+
cwd=repo_root,
|
|
104
|
+
capture_output=not verbose,
|
|
105
|
+
text=True
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if result.returncode == 0:
|
|
109
|
+
if verbose:
|
|
110
|
+
print(" ✓ Git LFS initialized")
|
|
111
|
+
return True
|
|
112
|
+
else:
|
|
113
|
+
if verbose:
|
|
114
|
+
print(f" ⚠️ Git LFS initialization warning: {result.stderr}")
|
|
115
|
+
return True # Not critical - continue anyway
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ensure_docker_containers(repo_root: Path, verbose: bool = False) -> bool:
|
|
119
|
+
"""
|
|
120
|
+
Ensure Docker containers are running from a docker-compose-ut.yml or docker-compose.yml file.
|
|
121
|
+
|
|
122
|
+
Searches for a compose file in common locations under the repo root.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
repo_root: Repository root directory
|
|
126
|
+
verbose: Print verbose output
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if containers are running (or no compose file found), False on failure
|
|
130
|
+
"""
|
|
131
|
+
# Search for a docker-compose file in common locations
|
|
132
|
+
candidates = [
|
|
133
|
+
repo_root / "docker-compose-ut.yml",
|
|
134
|
+
repo_root / "docker-compose.yml",
|
|
135
|
+
] + list(repo_root.rglob("docker-compose-ut.yml"))
|
|
136
|
+
|
|
137
|
+
docker_compose_path = next((p for p in candidates if p.exists()), None)
|
|
138
|
+
|
|
139
|
+
if docker_compose_path is None:
|
|
140
|
+
if verbose:
|
|
141
|
+
print("ℹ️ No docker-compose file found, skipping Docker check")
|
|
142
|
+
return True # No compose file in this repo, skip Docker check
|
|
143
|
+
|
|
144
|
+
if verbose:
|
|
145
|
+
print("🐳 Checking Docker containers...", flush=True)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
# Check if containers are running
|
|
149
|
+
# Note: In Docker Compose v5.x, ps -q may return exit code 1 when no containers exist
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
["docker-compose", "-f", str(docker_compose_path), "ps", "-q"],
|
|
152
|
+
capture_output=True,
|
|
153
|
+
text=True,
|
|
154
|
+
check=False # Don't raise on non-zero exit (containers may not exist yet)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# If ps command failed or no containers running, start them
|
|
158
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
159
|
+
# No containers running or project doesn't exist yet, start them
|
|
160
|
+
if verbose:
|
|
161
|
+
print(" Starting Docker containers...")
|
|
162
|
+
start_result = subprocess.run(
|
|
163
|
+
["docker-compose", "-f", str(docker_compose_path), "up", "-d"],
|
|
164
|
+
capture_output=not verbose,
|
|
165
|
+
text=True,
|
|
166
|
+
check=False
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if start_result.returncode != 0:
|
|
170
|
+
print(f"❌ Failed to start Docker containers (exit code {start_result.returncode})", file=sys.stderr)
|
|
171
|
+
if start_result.stderr:
|
|
172
|
+
print(start_result.stderr, file=sys.stderr)
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
if verbose:
|
|
176
|
+
print(" ✓ Docker containers started")
|
|
177
|
+
else:
|
|
178
|
+
if verbose:
|
|
179
|
+
print(" ✓ Docker containers already running")
|
|
180
|
+
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
print("❌ docker-compose not found. Install it: https://docs.docker.com/compose/install/", file=sys.stderr)
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_preflight_scripts(repo_root: Path, verbose: bool = False) -> bool:
|
|
189
|
+
"""
|
|
190
|
+
Run preflight scripts for Neo4j and Postgres.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
repo_root: Repository root directory
|
|
194
|
+
verbose: Print verbose output
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
True if all preflight scripts passed, False otherwise
|
|
198
|
+
"""
|
|
199
|
+
script_names = [
|
|
200
|
+
"preflight-postgres.sh",
|
|
201
|
+
"preflight-neo4j.sh"
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
if verbose:
|
|
205
|
+
print("🔧 Running preflight checks...", flush=True)
|
|
206
|
+
|
|
207
|
+
for script_name in script_names:
|
|
208
|
+
# Try multiple possible locations for the script
|
|
209
|
+
possible_locations = [
|
|
210
|
+
repo_root / "scripts" / script_name, # Workspace root scripts
|
|
211
|
+
Path.cwd() / "scripts" / script_name, # Current directory scripts
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
script = None
|
|
215
|
+
for location in possible_locations:
|
|
216
|
+
if location.exists():
|
|
217
|
+
script = location
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
if script is None:
|
|
221
|
+
if verbose:
|
|
222
|
+
print(f" ⚠️ Preflight script not found: {script_name} (tried {len(possible_locations)} locations)")
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
if verbose:
|
|
227
|
+
print(f" Running {script.name}...", flush=True)
|
|
228
|
+
|
|
229
|
+
result = subprocess.run(
|
|
230
|
+
[str(script)],
|
|
231
|
+
cwd=repo_root,
|
|
232
|
+
capture_output=not verbose,
|
|
233
|
+
text=True,
|
|
234
|
+
check=True
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if verbose:
|
|
238
|
+
print(f" ✓ {script.name} passed")
|
|
239
|
+
|
|
240
|
+
except subprocess.CalledProcessError as e:
|
|
241
|
+
print(f"❌ Preflight check failed: {script.name}", file=sys.stderr)
|
|
242
|
+
if not verbose and e.stderr:
|
|
243
|
+
print(e.stderr, file=sys.stderr)
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
if verbose:
|
|
247
|
+
print(" ✓ All preflight checks passed")
|
|
248
|
+
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def should_exclude_test(test_name: str) -> bool:
|
|
253
|
+
"""
|
|
254
|
+
Check if a test should be excluded from runs and mapping.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
test_name: Test name or path
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if test should be excluded
|
|
261
|
+
"""
|
|
262
|
+
# Exclude patterns
|
|
263
|
+
exclude_patterns = [
|
|
264
|
+
'test_mypy.py', # Skip mypy type checking tests
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
for pattern in exclude_patterns:
|
|
268
|
+
if pattern in test_name:
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def normalize_file_path(file_path: str) -> str:
|
|
275
|
+
"""
|
|
276
|
+
Normalize source file path to match database format.
|
|
277
|
+
|
|
278
|
+
Strips the 'src/' prefix that git diff includes but coverage.py doesn't store.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
file_path: File path from git diff (may have src/ prefix)
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Normalized file path without src/ prefix
|
|
285
|
+
"""
|
|
286
|
+
if file_path.startswith('src/'):
|
|
287
|
+
return file_path[4:] # Strip 'src/'
|
|
288
|
+
return file_path
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def strip_test_parameters(test_name: str) -> str:
|
|
292
|
+
"""
|
|
293
|
+
Strip parametrized test arguments from test name.
|
|
294
|
+
|
|
295
|
+
For example:
|
|
296
|
+
'test_foo.py::test_bar[arg1-arg2]' -> 'test_foo.py::test_bar'
|
|
297
|
+
'test_foo.py::test_bar' -> 'test_foo.py::test_bar'
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
test_name: Test name possibly with parameters in brackets
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Test name without parameters
|
|
304
|
+
"""
|
|
305
|
+
# Find the last '[' that indicates parameter start
|
|
306
|
+
bracket_idx = test_name.rfind('[')
|
|
307
|
+
if bracket_idx != -1:
|
|
308
|
+
return test_name[:bracket_idx]
|
|
309
|
+
return test_name
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def normalize_test_path(test_name: str) -> str:
|
|
313
|
+
"""
|
|
314
|
+
Normalize test path to match database format.
|
|
315
|
+
|
|
316
|
+
Strips common prefixes like 'unit_tests/', 'tests/', etc. to ensure
|
|
317
|
+
collected test names match what's stored in the coverage database.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
test_name: Test name with potential prefix
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Normalized test name without prefix
|
|
324
|
+
"""
|
|
325
|
+
# Common test directory prefixes to strip
|
|
326
|
+
prefixes = ['unit_tests/', 'tests/', 'test/', './']
|
|
327
|
+
|
|
328
|
+
for prefix in prefixes:
|
|
329
|
+
if test_name.startswith(prefix):
|
|
330
|
+
return test_name[len(prefix):]
|
|
331
|
+
|
|
332
|
+
return test_name
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def denormalize_test_path(test_name: str, repo_root: Path) -> str:
|
|
336
|
+
"""
|
|
337
|
+
Convert normalized test path back to pytest-compatible path.
|
|
338
|
+
|
|
339
|
+
Adds back the 'unit_tests/' or 'tests/' prefix by checking which exists.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
test_name: Normalized test name (without prefix)
|
|
343
|
+
repo_root: Repository root directory
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Full pytest-compatible test path
|
|
347
|
+
"""
|
|
348
|
+
# If already has a prefix, return as-is
|
|
349
|
+
if test_name.startswith(('unit_tests/', 'tests/', 'test/', './')):
|
|
350
|
+
return test_name
|
|
351
|
+
|
|
352
|
+
# Try common test directory prefixes
|
|
353
|
+
prefixes = ['unit_tests/', 'tests/', 'test/']
|
|
354
|
+
|
|
355
|
+
for prefix in prefixes:
|
|
356
|
+
# Extract file path from test name (before ::)
|
|
357
|
+
file_part = test_name.split('::')[0]
|
|
358
|
+
full_path = repo_root / prefix / file_part
|
|
359
|
+
|
|
360
|
+
if full_path.exists():
|
|
361
|
+
return prefix + test_name
|
|
362
|
+
|
|
363
|
+
# If no prefix works, return normalized name (pytest will error, but at least we tried)
|
|
364
|
+
return test_name
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def collect_all_tests(repo_root: Path, test_dir: str = None, verbose: bool = False) -> Set[str]:
|
|
368
|
+
"""
|
|
369
|
+
Collect all available tests using pytest --collect-only.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
repo_root: Repository root directory
|
|
373
|
+
test_dir: Test directory to collect from (default: auto-detect 'tests' or 'unit_tests')
|
|
374
|
+
verbose: Print verbose output
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Set of all test names in the format "path/to/test.py::TestClass::test_method"
|
|
378
|
+
(excluding tests that should be skipped)
|
|
379
|
+
"""
|
|
380
|
+
if test_dir is None:
|
|
381
|
+
test_dir = "tests" if (repo_root / "tests").exists() else "unit_tests"
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
# Use --quiet to get node IDs only
|
|
385
|
+
result = subprocess.run(
|
|
386
|
+
[sys.executable, "-m", "pytest", "--collect-only", "-q", test_dir],
|
|
387
|
+
cwd=repo_root,
|
|
388
|
+
capture_output=True,
|
|
389
|
+
text=True,
|
|
390
|
+
check=True
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Parse output - pytest --collect-only -q -q outputs test node IDs
|
|
394
|
+
test_names = set()
|
|
395
|
+
excluded_count = 0
|
|
396
|
+
|
|
397
|
+
for line in result.stdout.split('\n'):
|
|
398
|
+
line = line.strip()
|
|
399
|
+
# Skip empty lines and summary lines
|
|
400
|
+
if not line or 'collected' in line.lower() or 'skipped' in line.lower():
|
|
401
|
+
continue
|
|
402
|
+
# Lines like "path/to/test.py::test_function" are test node IDs
|
|
403
|
+
if '::' in line:
|
|
404
|
+
# Check if test should be excluded
|
|
405
|
+
if should_exclude_test(line):
|
|
406
|
+
excluded_count += 1
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
# Add the test node ID
|
|
410
|
+
test_names.add(line)
|
|
411
|
+
|
|
412
|
+
if verbose:
|
|
413
|
+
print(f"🔍 Collected {len(test_names)} total tests from pytest", flush=True)
|
|
414
|
+
if excluded_count > 0:
|
|
415
|
+
print(f" ⚠️ Excluded {excluded_count} tests (test_mypy.py, etc.)", flush=True)
|
|
416
|
+
|
|
417
|
+
return test_names
|
|
418
|
+
|
|
419
|
+
except subprocess.CalledProcessError as e:
|
|
420
|
+
if verbose:
|
|
421
|
+
print(f"Warning: Could not collect tests: {e.stderr}", file=sys.stderr)
|
|
422
|
+
return set()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def get_mapped_tests(db_or_path, verbose: bool = False) -> Set[str]:
|
|
426
|
+
"""
|
|
427
|
+
Get all tests currently in the mapping database.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
db_or_path: Path to database or initialized DB object
|
|
431
|
+
verbose: Print verbose output
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
Set of test names from the database
|
|
435
|
+
"""
|
|
436
|
+
if hasattr(db_or_path, 'get_all_test_names'):
|
|
437
|
+
mapped_tests = db_or_path.get_all_test_names()
|
|
438
|
+
if verbose:
|
|
439
|
+
print(f"📊 Found {len(mapped_tests)} tests in cloud mapping database", flush=True)
|
|
440
|
+
return mapped_tests
|
|
441
|
+
|
|
442
|
+
if not isinstance(db_or_path, Path) or not db_or_path.exists():
|
|
443
|
+
if verbose:
|
|
444
|
+
print(f"Warning: Mapping database not found: {db_or_path}", file=sys.stderr)
|
|
445
|
+
return set()
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
with TestMappingDBV2(db_or_path) as db:
|
|
449
|
+
mapped_tests = db.get_all_test_names()
|
|
450
|
+
|
|
451
|
+
if verbose:
|
|
452
|
+
print(f"📊 Found {len(mapped_tests)} tests in mapping database", flush=True)
|
|
453
|
+
|
|
454
|
+
return mapped_tests
|
|
455
|
+
|
|
456
|
+
except Exception as e:
|
|
457
|
+
if verbose:
|
|
458
|
+
print(f"Warning: Could not query mapping database: {e}", file=sys.stderr)
|
|
459
|
+
return set()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def find_unmapped_tests(
|
|
463
|
+
repo_root: Path,
|
|
464
|
+
db_or_path,
|
|
465
|
+
test_dir: str = None,
|
|
466
|
+
verbose: bool = False
|
|
467
|
+
) -> Set[str]:
|
|
468
|
+
"""
|
|
469
|
+
Find tests that exist in the codebase but aren't in the mapping database yet.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
repo_root: Repository root directory
|
|
473
|
+
db_or_path: Path to test mapping database or initialized DB object
|
|
474
|
+
test_dir: Test directory to collect from (default: "unit_tests")
|
|
475
|
+
verbose: Print verbose output
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Set of unmapped test names (with original paths suitable for pytest)
|
|
479
|
+
"""
|
|
480
|
+
all_tests = collect_all_tests(repo_root, test_dir, verbose)
|
|
481
|
+
mapped_tests = get_mapped_tests(db_or_path, verbose)
|
|
482
|
+
|
|
483
|
+
# Normalize both sides for comparison (comparing by base test name without parameters)
|
|
484
|
+
# Build a mapping from normalized name (without params) -> list of original names
|
|
485
|
+
from collections import defaultdict
|
|
486
|
+
normalized_to_originals = defaultdict(list)
|
|
487
|
+
for t in all_tests:
|
|
488
|
+
# Strip parameters and normalize path
|
|
489
|
+
test_without_params = strip_test_parameters(t)
|
|
490
|
+
norm_key = normalize_test_path(test_without_params)
|
|
491
|
+
# Keep track of all original test names (with params and prefix)
|
|
492
|
+
normalized_to_originals[norm_key].append(t)
|
|
493
|
+
|
|
494
|
+
# For mapped tests, also strip parameters and normalize
|
|
495
|
+
normalized_mapped = set()
|
|
496
|
+
for t in mapped_tests:
|
|
497
|
+
test_without_params = strip_test_parameters(t)
|
|
498
|
+
norm_key = normalize_test_path(test_without_params)
|
|
499
|
+
normalized_mapped.add(norm_key)
|
|
500
|
+
|
|
501
|
+
# Find normalized names that aren't mapped
|
|
502
|
+
unmapped_normalized = set(normalized_to_originals.keys()) - normalized_mapped
|
|
503
|
+
|
|
504
|
+
# Convert back to original names (with unit_tests/ prefix) for pytest
|
|
505
|
+
# Include all parameterized versions of unmapped tests
|
|
506
|
+
unmapped = set()
|
|
507
|
+
for norm in unmapped_normalized:
|
|
508
|
+
unmapped.update(normalized_to_originals[norm])
|
|
509
|
+
|
|
510
|
+
if verbose:
|
|
511
|
+
print(f"\n📊 Test discovery stats:", flush=True)
|
|
512
|
+
print(f" Total tests found: {len(all_tests)}", flush=True)
|
|
513
|
+
print(f" Already mapped: {len(mapped_tests)}", flush=True)
|
|
514
|
+
print(f" Unmapped (need to run): {len(unmapped)}", flush=True)
|
|
515
|
+
|
|
516
|
+
if verbose and unmapped:
|
|
517
|
+
print(f"\n⚠️ Found {len(unmapped)} unmapped tests (not in mapping DB)", flush=True)
|
|
518
|
+
print(" These will be run individually to build coverage mapping", flush=True)
|
|
519
|
+
for test in sorted(list(unmapped)[:5]):
|
|
520
|
+
print(f" - {test}", flush=True)
|
|
521
|
+
if len(unmapped) > 5:
|
|
522
|
+
print(f" ... and {len(unmapped) - 5} more", flush=True)
|
|
523
|
+
|
|
524
|
+
return unmapped
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def run_test_chunk_with_mapping_update(
|
|
528
|
+
repo_root: Path,
|
|
529
|
+
test_names: list,
|
|
530
|
+
mapping_db_path: Path,
|
|
531
|
+
verbose: bool = False
|
|
532
|
+
) -> tuple[Set[str], Set[str], bool]:
|
|
533
|
+
"""
|
|
534
|
+
Run tests with coverage and update the mapping database.
|
|
535
|
+
|
|
536
|
+
This is used for unmapped tests to build the coverage mapping.
|
|
537
|
+
Output flows directly to the terminal so users can see test failures.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
repo_root: Repository root directory
|
|
541
|
+
test_names: List of test names to run
|
|
542
|
+
mapping_db_path: Path to test mapping database
|
|
543
|
+
verbose: Print verbose output
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
Tuple of (passed_tests, failed_tests, all_passed)
|
|
547
|
+
Note: failed_tests will be empty since pytest output shows failures directly
|
|
548
|
+
"""
|
|
549
|
+
from .test_mapping_db_v2 import normalize_test_name
|
|
550
|
+
|
|
551
|
+
# Normalize test names to remove parametrization
|
|
552
|
+
# This prevents issues with dynamic parameters (lambdas, complex objects)
|
|
553
|
+
# Running the base test name (test_foo) will automatically run all parametrizations
|
|
554
|
+
normalized_tests = list(set(normalize_test_name(t) for t in test_names))
|
|
555
|
+
|
|
556
|
+
# Run tests with coverage - output flows directly to terminal
|
|
557
|
+
cmd = [
|
|
558
|
+
sys.executable, "-m", "pytest",
|
|
559
|
+
"--cov=src",
|
|
560
|
+
"--cov-context=test",
|
|
561
|
+
"--cov-append",
|
|
562
|
+
"--cov-report=",
|
|
563
|
+
"-v", # Always verbose so users see test failures
|
|
564
|
+
"--tb=short", # Short traceback format
|
|
565
|
+
] + normalized_tests
|
|
566
|
+
|
|
567
|
+
result = subprocess.run(
|
|
568
|
+
cmd,
|
|
569
|
+
cwd=repo_root
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# Determine passed/failed from exit code
|
|
573
|
+
# pytest returns 0 if all passed, non-zero if any failed
|
|
574
|
+
if result.returncode == 0:
|
|
575
|
+
passed = set(test_names)
|
|
576
|
+
failed = set()
|
|
577
|
+
else:
|
|
578
|
+
# Some failed - we'll mark all as "run" but return the failed status
|
|
579
|
+
# The pytest output above already showed which ones failed
|
|
580
|
+
passed = set(test_names)
|
|
581
|
+
failed = set() # We don't track individual failures since output was shown
|
|
582
|
+
|
|
583
|
+
# Update mapping database with new coverage
|
|
584
|
+
# This happens REGARDLESS of whether tests passed or failed
|
|
585
|
+
print(f"\n{'─'*80}", flush=True)
|
|
586
|
+
print(f"📊 Updating mapping database after test run...", flush=True)
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
coverage_file = repo_root / ".coverage"
|
|
590
|
+
if not coverage_file.exists():
|
|
591
|
+
print(f" ⚠️ No coverage file found at {coverage_file}", flush=True)
|
|
592
|
+
print(f" Tests may have crashed before generating coverage data.", flush=True)
|
|
593
|
+
return passed, failed, result.returncode == 0
|
|
594
|
+
|
|
595
|
+
print(f" ✓ Coverage file found: {coverage_file}", flush=True)
|
|
596
|
+
|
|
597
|
+
# Get DB stats before import
|
|
598
|
+
tests_before = 0
|
|
599
|
+
tests_after = 0
|
|
600
|
+
tests_added = 0
|
|
601
|
+
|
|
602
|
+
with TestMappingDBV2(mapping_db_path) as db:
|
|
603
|
+
stats_before = db.get_stats()
|
|
604
|
+
tests_before = stats_before['total_tests']
|
|
605
|
+
|
|
606
|
+
print(f" 📈 Database before import: {tests_before} tests", flush=True)
|
|
607
|
+
|
|
608
|
+
# Import coverage
|
|
609
|
+
db.import_from_coverage(coverage_file, incremental=True)
|
|
610
|
+
|
|
611
|
+
# Get stats after import
|
|
612
|
+
stats_after = db.get_stats()
|
|
613
|
+
tests_after = stats_after['total_tests']
|
|
614
|
+
tests_added = tests_after - tests_before
|
|
615
|
+
|
|
616
|
+
print(f" 📈 Database after import (pre-commit): {tests_after} tests", flush=True)
|
|
617
|
+
|
|
618
|
+
# Explicitly commit to ensure data is persisted
|
|
619
|
+
db.conn.commit()
|
|
620
|
+
print(f" ✓ Commit executed", flush=True)
|
|
621
|
+
|
|
622
|
+
# Verify commit persisted by opening a new connection
|
|
623
|
+
with TestMappingDBV2(mapping_db_path) as db_verify:
|
|
624
|
+
stats_verify = db_verify.get_stats()
|
|
625
|
+
tests_verify = stats_verify['total_tests']
|
|
626
|
+
print(f" 📈 Database verification (new connection): {tests_verify} tests", flush=True)
|
|
627
|
+
|
|
628
|
+
if tests_verify != tests_after:
|
|
629
|
+
print(f" ⚠️ WARNING: Commit may have failed! Expected {tests_after}, got {tests_verify}", flush=True)
|
|
630
|
+
|
|
631
|
+
print(f" ✅ Mapping updated: {tests_added} new tests added to DB (ran {len(test_names)}, DB now has {tests_after})", flush=True)
|
|
632
|
+
|
|
633
|
+
if tests_added < len(test_names) * 0.8: # Less than 80% were new
|
|
634
|
+
print(f" ⚠️ WARNING: Only {tests_added}/{len(test_names)} tests were new to the database!", flush=True)
|
|
635
|
+
print(f" This might indicate duplicates or tests already mapped.", flush=True)
|
|
636
|
+
|
|
637
|
+
# Move coverage file to backup to avoid conflicts in next chunk
|
|
638
|
+
coverage_backup = repo_root / f".coverage.chunk_{len(test_names)}"
|
|
639
|
+
if coverage_backup.exists():
|
|
640
|
+
coverage_backup.unlink()
|
|
641
|
+
coverage_file.rename(coverage_backup)
|
|
642
|
+
print(f" ✓ Coverage backed up to {coverage_backup.name}", flush=True)
|
|
643
|
+
print(f"{'─'*80}", flush=True)
|
|
644
|
+
|
|
645
|
+
except Exception as e:
|
|
646
|
+
print(f" ❌ Could not update mapping: {e}", flush=True)
|
|
647
|
+
print(f"{'─'*80}", flush=True)
|
|
648
|
+
import traceback
|
|
649
|
+
if verbose:
|
|
650
|
+
print(f" Full traceback:", flush=True)
|
|
651
|
+
traceback.print_exc()
|
|
652
|
+
|
|
653
|
+
# Return success/failure based on exit code
|
|
654
|
+
# Note: We return empty failed set since pytest output already showed failures
|
|
655
|
+
return passed, failed, result.returncode == 0
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def run_single_test_with_mapping_update(
|
|
659
|
+
repo_root: Path,
|
|
660
|
+
test_name: str,
|
|
661
|
+
mapping_db_path: Path,
|
|
662
|
+
verbose: bool = False
|
|
663
|
+
) -> bool:
|
|
664
|
+
"""
|
|
665
|
+
Run a single test with coverage and update the mapping database.
|
|
666
|
+
|
|
667
|
+
This is used for unmapped tests to gradually build the coverage mapping.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
repo_root: Repository root directory
|
|
671
|
+
test_name: Test name to run
|
|
672
|
+
mapping_db_path: Path to test mapping database
|
|
673
|
+
verbose: Print verbose output
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
True if test passed and mapping updated, False otherwise
|
|
677
|
+
"""
|
|
678
|
+
from .test_mapping_db_v2 import normalize_test_name
|
|
679
|
+
|
|
680
|
+
# Normalize test name to remove parametrization
|
|
681
|
+
normalized_test = normalize_test_name(test_name)
|
|
682
|
+
|
|
683
|
+
if verbose:
|
|
684
|
+
print(f"\n Running unmapped test: {normalized_test}")
|
|
685
|
+
|
|
686
|
+
# Run single test with coverage
|
|
687
|
+
cmd = [
|
|
688
|
+
"python", "-m", "pytest",
|
|
689
|
+
"--cov=src",
|
|
690
|
+
"--cov-context=test",
|
|
691
|
+
"--cov-append",
|
|
692
|
+
"--cov-report=",
|
|
693
|
+
"-v",
|
|
694
|
+
normalized_test
|
|
695
|
+
]
|
|
696
|
+
|
|
697
|
+
result = subprocess.run(
|
|
698
|
+
cmd,
|
|
699
|
+
cwd=repo_root,
|
|
700
|
+
capture_output=True,
|
|
701
|
+
text=True
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
if result.returncode != 0:
|
|
705
|
+
if verbose:
|
|
706
|
+
print(f" ✗ Test failed: {test_name}")
|
|
707
|
+
return False
|
|
708
|
+
|
|
709
|
+
# Update mapping database with new coverage
|
|
710
|
+
try:
|
|
711
|
+
coverage_file = repo_root / ".coverage"
|
|
712
|
+
if coverage_file.exists():
|
|
713
|
+
with TestMappingDBV2(mapping_db_path) as db:
|
|
714
|
+
db.import_from_coverage(coverage_file, incremental=True)
|
|
715
|
+
if verbose:
|
|
716
|
+
print(f" ✓ Mapping updated for: {test_name}")
|
|
717
|
+
return True
|
|
718
|
+
except Exception as e:
|
|
719
|
+
if verbose:
|
|
720
|
+
print(f" ⚠️ Could not update mapping: {e}")
|
|
721
|
+
return True # Still return True since test passed
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def run_unmapped_tests_iteratively(
|
|
725
|
+
repo_root: Path,
|
|
726
|
+
unmapped_tests: Set[str],
|
|
727
|
+
mapping_db_path: Path,
|
|
728
|
+
verbose: bool = False,
|
|
729
|
+
chunk_size: int = 1000
|
|
730
|
+
) -> tuple[bool, int]:
|
|
731
|
+
"""
|
|
732
|
+
Run unmapped tests with automatic chunking to avoid OS argument limits.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
repo_root: Repository root directory
|
|
736
|
+
unmapped_tests: Set of unmapped test names
|
|
737
|
+
mapping_db_path: Path to test mapping database
|
|
738
|
+
verbose: Print verbose output
|
|
739
|
+
chunk_size: Maximum tests per chunk (default 1000 to avoid ARG_MAX)
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
Tuple of (all_passed, count_run)
|
|
743
|
+
"""
|
|
744
|
+
if not unmapped_tests:
|
|
745
|
+
return True, 0
|
|
746
|
+
|
|
747
|
+
test_list = sorted(unmapped_tests)
|
|
748
|
+
total_tests = len(test_list)
|
|
749
|
+
|
|
750
|
+
# Determine if we need to chunk (OS has ARG_MAX limit)
|
|
751
|
+
# Safe threshold: 1000 tests per chunk to avoid "Argument list too long"
|
|
752
|
+
needs_chunking = total_tests > chunk_size
|
|
753
|
+
|
|
754
|
+
print(f"\n{'='*80}", flush=True)
|
|
755
|
+
print(f"Building coverage mapping for {total_tests} unmapped test(s)", flush=True)
|
|
756
|
+
if needs_chunking:
|
|
757
|
+
num_chunks = (total_tests + chunk_size - 1) // chunk_size
|
|
758
|
+
print(f"Running in {num_chunks} chunks of up to {chunk_size} tests...", flush=True)
|
|
759
|
+
print(f"💡 Each chunk updates the database immediately - safe to interrupt/resume", flush=True)
|
|
760
|
+
else:
|
|
761
|
+
print(f"Running all tests at once...", flush=True)
|
|
762
|
+
print(f"{'='*80}", flush=True)
|
|
763
|
+
|
|
764
|
+
# Run tests in chunks
|
|
765
|
+
all_passed = True
|
|
766
|
+
count_run = 0
|
|
767
|
+
|
|
768
|
+
if needs_chunking:
|
|
769
|
+
# Break into chunks
|
|
770
|
+
for i in range(0, total_tests, chunk_size):
|
|
771
|
+
chunk = test_list[i:i + chunk_size]
|
|
772
|
+
chunk_num = (i // chunk_size) + 1
|
|
773
|
+
total_chunks = (total_tests + chunk_size - 1) // chunk_size
|
|
774
|
+
|
|
775
|
+
# Verify chunk tests are actually unmapped
|
|
776
|
+
if verbose:
|
|
777
|
+
with TestMappingDBV2(mapping_db_path) as db:
|
|
778
|
+
cursor = db.conn.cursor()
|
|
779
|
+
already_mapped = []
|
|
780
|
+
for test in chunk[:10]: # Check first 10 tests
|
|
781
|
+
cursor.execute("SELECT COUNT(*) FROM test_coverage_ranges WHERE test_name = ?", (test,))
|
|
782
|
+
if cursor.fetchone()[0] > 0:
|
|
783
|
+
already_mapped.append(test)
|
|
784
|
+
if already_mapped:
|
|
785
|
+
print(f" ⚠️ WARNING: {len(already_mapped)}/10 sampled tests already in DB:", flush=True)
|
|
786
|
+
for t in already_mapped[:3]:
|
|
787
|
+
print(f" - {t}", flush=True)
|
|
788
|
+
|
|
789
|
+
print(f"\n📦 Chunk {chunk_num}/{total_chunks}: Running {len(chunk)} tests (mapped so far: {count_run})...", flush=True)
|
|
790
|
+
|
|
791
|
+
passed, failed, chunk_passed = run_test_chunk_with_mapping_update(
|
|
792
|
+
repo_root,
|
|
793
|
+
chunk,
|
|
794
|
+
mapping_db_path,
|
|
795
|
+
verbose
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
count_run += len(chunk)
|
|
799
|
+
|
|
800
|
+
if not chunk_passed:
|
|
801
|
+
all_passed = False
|
|
802
|
+
print(f" ⚠️ Some tests in chunk {chunk_num} failed (continuing...)", flush=True)
|
|
803
|
+
else:
|
|
804
|
+
print(f" ✓ Chunk {chunk_num} passed - {count_run}/{total_tests} tests mapped", flush=True)
|
|
805
|
+
|
|
806
|
+
# Verify tests were actually added to database
|
|
807
|
+
with TestMappingDBV2(mapping_db_path) as db:
|
|
808
|
+
stats = db.get_stats()
|
|
809
|
+
if verbose:
|
|
810
|
+
print(f" 📊 Database now has {stats['total_tests']} tests, {stats['total_mappings']} mappings", flush=True)
|
|
811
|
+
else:
|
|
812
|
+
# Run all at once
|
|
813
|
+
print(f"\nRunning {total_tests} unmapped tests with coverage...", flush=True)
|
|
814
|
+
|
|
815
|
+
passed, failed, all_passed = run_test_chunk_with_mapping_update(
|
|
816
|
+
repo_root,
|
|
817
|
+
test_list,
|
|
818
|
+
mapping_db_path,
|
|
819
|
+
verbose
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
count_run = total_tests
|
|
823
|
+
|
|
824
|
+
if all_passed:
|
|
825
|
+
print(f"\n✓ Successfully mapped {count_run} test(s)", flush=True)
|
|
826
|
+
else:
|
|
827
|
+
print(f"\n❌ Some unmapped tests failed (see output above)", flush=True)
|
|
828
|
+
print(f" The test failures are shown in the pytest output above.", flush=True)
|
|
829
|
+
print(f" All {count_run} tests were still added to the mapping database.", flush=True)
|
|
830
|
+
|
|
831
|
+
# Clean up backup coverage files
|
|
832
|
+
for backup in repo_root.glob(".coverage.chunk_*"):
|
|
833
|
+
try:
|
|
834
|
+
backup.unlink()
|
|
835
|
+
if verbose:
|
|
836
|
+
print(f" 🧹 Cleaned up {backup.name}", flush=True)
|
|
837
|
+
except Exception as e:
|
|
838
|
+
if verbose:
|
|
839
|
+
print(f" ⚠️ Could not remove {backup.name}: {e}", flush=True)
|
|
840
|
+
|
|
841
|
+
return all_passed, count_run
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def get_unstaged_test_files(repo_root: Path) -> Set[str]:
|
|
845
|
+
"""
|
|
846
|
+
Find test files in the current commit (staged changes).
|
|
847
|
+
|
|
848
|
+
Returns:
|
|
849
|
+
Set of test file paths that are being committed
|
|
850
|
+
"""
|
|
851
|
+
try:
|
|
852
|
+
# Get staged files
|
|
853
|
+
result = subprocess.run(
|
|
854
|
+
["git", "diff", "--cached", "--name-only", "--diff-filter=d"],
|
|
855
|
+
cwd=repo_root,
|
|
856
|
+
capture_output=True,
|
|
857
|
+
text=True,
|
|
858
|
+
check=True
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
staged_files = result.stdout.strip().split('\n')
|
|
862
|
+
|
|
863
|
+
# Filter for test files
|
|
864
|
+
test_files = set()
|
|
865
|
+
for file_path in staged_files:
|
|
866
|
+
if file_path.strip():
|
|
867
|
+
# Check if it's a test file
|
|
868
|
+
if 'test_' in file_path or '_test.py' in file_path or '/tests/' in file_path:
|
|
869
|
+
# Exclude test_mypy.py and other excluded patterns
|
|
870
|
+
if not should_exclude_test(file_path):
|
|
871
|
+
test_files.add(file_path)
|
|
872
|
+
|
|
873
|
+
return test_files
|
|
874
|
+
|
|
875
|
+
except subprocess.CalledProcessError as e:
|
|
876
|
+
print(f"Warning: Could not get staged test files: {e.stderr}", file=sys.stderr)
|
|
877
|
+
return set()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def get_new_tests_in_commit(repo_root: Path) -> Set[str]:
|
|
881
|
+
"""
|
|
882
|
+
Find NEW test files being added in this commit.
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
Set of new test file paths
|
|
886
|
+
"""
|
|
887
|
+
try:
|
|
888
|
+
# Get added files (new files)
|
|
889
|
+
result = subprocess.run(
|
|
890
|
+
["git", "diff", "--cached", "--name-only", "--diff-filter=A"],
|
|
891
|
+
cwd=repo_root,
|
|
892
|
+
capture_output=True,
|
|
893
|
+
text=True,
|
|
894
|
+
check=True
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
new_files = result.stdout.strip().split('\n')
|
|
898
|
+
|
|
899
|
+
# Filter for test files
|
|
900
|
+
new_test_files = set()
|
|
901
|
+
for file_path in new_files:
|
|
902
|
+
if file_path.strip():
|
|
903
|
+
if 'test_' in file_path or '_test.py' in file_path or '/tests/' in file_path:
|
|
904
|
+
# Exclude test_mypy.py and other excluded patterns
|
|
905
|
+
if not should_exclude_test(file_path):
|
|
906
|
+
new_test_files.add(file_path)
|
|
907
|
+
|
|
908
|
+
return new_test_files
|
|
909
|
+
|
|
910
|
+
except subprocess.CalledProcessError as e:
|
|
911
|
+
print(f"Warning: Could not get new test files: {e.stderr}", file=sys.stderr)
|
|
912
|
+
return set()
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def combine_coverage_files(repo_root: Path, new_coverage: Path):
|
|
916
|
+
"""
|
|
917
|
+
Combine new coverage data with existing .coverage file.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
repo_root: Repository root directory
|
|
921
|
+
new_coverage: Path to new coverage file from test run
|
|
922
|
+
"""
|
|
923
|
+
existing_coverage = repo_root / ".coverage"
|
|
924
|
+
|
|
925
|
+
if not new_coverage.exists():
|
|
926
|
+
print(f"Warning: New coverage file not found: {new_coverage}", file=sys.stderr)
|
|
927
|
+
return
|
|
928
|
+
|
|
929
|
+
if not existing_coverage.exists():
|
|
930
|
+
# No existing coverage, just rename new one
|
|
931
|
+
print("No existing coverage file, using new coverage as baseline")
|
|
932
|
+
new_coverage.rename(existing_coverage)
|
|
933
|
+
return
|
|
934
|
+
|
|
935
|
+
# Use coverage combine to merge
|
|
936
|
+
print("Combining coverage data with existing .coverage...")
|
|
937
|
+
|
|
938
|
+
try:
|
|
939
|
+
# Backup existing coverage
|
|
940
|
+
backup = repo_root / ".coverage.backup"
|
|
941
|
+
if backup.exists():
|
|
942
|
+
backup.unlink()
|
|
943
|
+
|
|
944
|
+
existing_coverage.rename(backup)
|
|
945
|
+
|
|
946
|
+
# Move new coverage to a temporary name for combining
|
|
947
|
+
temp_new = repo_root / ".coverage.new"
|
|
948
|
+
if temp_new.exists():
|
|
949
|
+
temp_new.unlink()
|
|
950
|
+
new_coverage.rename(temp_new)
|
|
951
|
+
|
|
952
|
+
# Use coverage combine
|
|
953
|
+
result = subprocess.run(
|
|
954
|
+
[sys.executable, "-m", "coverage", "combine", str(backup), str(temp_new)],
|
|
955
|
+
cwd=repo_root,
|
|
956
|
+
capture_output=True,
|
|
957
|
+
text=True
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
if result.returncode != 0:
|
|
961
|
+
print(f"Warning: Coverage combine failed: {result.stderr}", file=sys.stderr)
|
|
962
|
+
# Restore backup
|
|
963
|
+
backup.rename(existing_coverage)
|
|
964
|
+
temp_new.rename(new_coverage)
|
|
965
|
+
else:
|
|
966
|
+
print("✓ Coverage data combined successfully")
|
|
967
|
+
# Clean up
|
|
968
|
+
if backup.exists():
|
|
969
|
+
backup.unlink()
|
|
970
|
+
if temp_new.exists():
|
|
971
|
+
temp_new.unlink()
|
|
972
|
+
|
|
973
|
+
except Exception as e:
|
|
974
|
+
print(f"Error combining coverage: {e}", file=sys.stderr)
|
|
975
|
+
# Try to restore backup
|
|
976
|
+
backup = repo_root / ".coverage.backup"
|
|
977
|
+
if backup.exists() and not existing_coverage.exists():
|
|
978
|
+
backup.rename(existing_coverage)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def find_affected_tests(
|
|
982
|
+
repo_root: Path,
|
|
983
|
+
target_branch: str,
|
|
984
|
+
mapping_db_path: Path,
|
|
985
|
+
verbose: bool = False
|
|
986
|
+
) -> Set[str]:
|
|
987
|
+
"""
|
|
988
|
+
Find tests affected by staged changes.
|
|
989
|
+
|
|
990
|
+
Args:
|
|
991
|
+
repo_root: Repository root
|
|
992
|
+
target_branch: Target branch to compare against
|
|
993
|
+
mapping_db_path: Path to test mapping SQLite database
|
|
994
|
+
verbose: Print verbose output
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
Set of test names to run
|
|
998
|
+
"""
|
|
999
|
+
# Initialize git parser
|
|
1000
|
+
git_parser = GitDiffParser(repo_root)
|
|
1001
|
+
|
|
1002
|
+
# Get diff for staged changes
|
|
1003
|
+
try:
|
|
1004
|
+
result = subprocess.run(
|
|
1005
|
+
["git", "diff", "--cached", "--unified=0", target_branch],
|
|
1006
|
+
cwd=repo_root,
|
|
1007
|
+
capture_output=True,
|
|
1008
|
+
text=True,
|
|
1009
|
+
check=True
|
|
1010
|
+
)
|
|
1011
|
+
diff_output = result.stdout
|
|
1012
|
+
except subprocess.CalledProcessError as e:
|
|
1013
|
+
print(f"Error getting staged diff: {e.stderr}", file=sys.stderr)
|
|
1014
|
+
return set()
|
|
1015
|
+
|
|
1016
|
+
# Parse diff
|
|
1017
|
+
changes = git_parser.parse_diff(diff_output)
|
|
1018
|
+
python_changes = git_parser.filter_python_files(changes)
|
|
1019
|
+
|
|
1020
|
+
if verbose:
|
|
1021
|
+
print(f"\n📝 Found {len(python_changes)} changed Python files", flush=True)
|
|
1022
|
+
if python_changes:
|
|
1023
|
+
for file_path in sorted(python_changes.keys()):
|
|
1024
|
+
print(f" • {file_path}", flush=True)
|
|
1025
|
+
|
|
1026
|
+
# Early exit if no Python changes
|
|
1027
|
+
if not python_changes:
|
|
1028
|
+
if verbose:
|
|
1029
|
+
print("✅ No Python files changed. No tests to run.", flush=True)
|
|
1030
|
+
return set()
|
|
1031
|
+
|
|
1032
|
+
# Get changed test files (always run these)
|
|
1033
|
+
changed_test_files = git_parser.get_changed_test_files(python_changes)
|
|
1034
|
+
|
|
1035
|
+
if verbose and changed_test_files:
|
|
1036
|
+
print(f"📝 Changed test files: {len(changed_test_files)}", flush=True)
|
|
1037
|
+
for test_file in sorted(changed_test_files):
|
|
1038
|
+
print(f" - {test_file}", flush=True)
|
|
1039
|
+
|
|
1040
|
+
# Find tests from mapping database
|
|
1041
|
+
affected_tests = set()
|
|
1042
|
+
|
|
1043
|
+
if not mapping_db_path.exists():
|
|
1044
|
+
print(f"⚠️ Warning: Test mapping database not found: {mapping_db_path}", file=sys.stderr)
|
|
1045
|
+
print(" Run: pintest update-mapping", file=sys.stderr)
|
|
1046
|
+
# Fall back to running only changed test files
|
|
1047
|
+
return changed_test_files
|
|
1048
|
+
|
|
1049
|
+
# Query database for affected tests
|
|
1050
|
+
with TestMappingDBV2(mapping_db_path) as db:
|
|
1051
|
+
stats = db.get_stats()
|
|
1052
|
+
if verbose:
|
|
1053
|
+
print(f"📊 Mapping DB: {stats['total_tests']} tests, {stats['total_files']} files, {stats['total_mappings']} mappings", flush=True)
|
|
1054
|
+
|
|
1055
|
+
for file_path, change in python_changes.items():
|
|
1056
|
+
# Skip test files (we already have them)
|
|
1057
|
+
if file_path in changed_test_files:
|
|
1058
|
+
continue
|
|
1059
|
+
|
|
1060
|
+
changed_lines = change.get_all_changed_lines()
|
|
1061
|
+
if not changed_lines:
|
|
1062
|
+
continue
|
|
1063
|
+
|
|
1064
|
+
# Normalize file path to match database format (strip src/ prefix)
|
|
1065
|
+
normalized_path = normalize_file_path(file_path)
|
|
1066
|
+
|
|
1067
|
+
# Find tests covering these lines
|
|
1068
|
+
tests = db.find_tests_for_file_lines(normalized_path, changed_lines)
|
|
1069
|
+
affected_tests.update(tests)
|
|
1070
|
+
|
|
1071
|
+
if verbose and tests:
|
|
1072
|
+
print(f"📍 {file_path}: {len(tests)} test(s) cover changed lines")
|
|
1073
|
+
for test in sorted(tests)[:3]:
|
|
1074
|
+
print(f" - {test}")
|
|
1075
|
+
if len(tests) > 3:
|
|
1076
|
+
print(f" ... and {len(tests) - 3} more")
|
|
1077
|
+
|
|
1078
|
+
# Always include changed test files
|
|
1079
|
+
all_tests = affected_tests | changed_test_files
|
|
1080
|
+
|
|
1081
|
+
# Add new test files (always run new tests)
|
|
1082
|
+
new_tests = get_new_tests_in_commit(repo_root)
|
|
1083
|
+
if new_tests:
|
|
1084
|
+
if verbose:
|
|
1085
|
+
print(f"✨ New test files (always run): {len(new_tests)}")
|
|
1086
|
+
for test_file in sorted(new_tests):
|
|
1087
|
+
print(f" + {test_file}")
|
|
1088
|
+
all_tests.update(new_tests)
|
|
1089
|
+
|
|
1090
|
+
# Filter out excluded tests (test_mypy.py, etc.)
|
|
1091
|
+
filtered_tests = {test for test in all_tests if not should_exclude_test(test)}
|
|
1092
|
+
excluded_count = len(all_tests) - len(filtered_tests)
|
|
1093
|
+
|
|
1094
|
+
if verbose and excluded_count > 0:
|
|
1095
|
+
print(f"⚠️ Excluded {excluded_count} tests (test_mypy.py, etc.)", flush=True)
|
|
1096
|
+
|
|
1097
|
+
return filtered_tests
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def run_tests_with_coverage(
|
|
1101
|
+
repo_root: Path,
|
|
1102
|
+
test_paths: Set[str],
|
|
1103
|
+
pytest_args: list = None,
|
|
1104
|
+
verbose: bool = False
|
|
1105
|
+
) -> bool:
|
|
1106
|
+
"""
|
|
1107
|
+
Run tests with coverage enabled.
|
|
1108
|
+
|
|
1109
|
+
Args:
|
|
1110
|
+
repo_root: Repository root
|
|
1111
|
+
test_paths: Paths to test files/modules to run (may be normalized)
|
|
1112
|
+
pytest_args: Additional pytest arguments
|
|
1113
|
+
verbose: Print verbose output
|
|
1114
|
+
|
|
1115
|
+
Returns:
|
|
1116
|
+
True if tests passed, False otherwise
|
|
1117
|
+
"""
|
|
1118
|
+
from .test_mapping_db_v2 import normalize_test_name
|
|
1119
|
+
|
|
1120
|
+
if not test_paths:
|
|
1121
|
+
print("✓ No tests to run")
|
|
1122
|
+
return True
|
|
1123
|
+
|
|
1124
|
+
# Denormalize test paths for pytest (add back unit_tests/ prefix if needed)
|
|
1125
|
+
denormalized_paths = {denormalize_test_path(test, repo_root) for test in test_paths}
|
|
1126
|
+
|
|
1127
|
+
# Separate file paths from specific test node IDs
|
|
1128
|
+
test_files = set()
|
|
1129
|
+
test_node_ids = set()
|
|
1130
|
+
|
|
1131
|
+
for path in denormalized_paths:
|
|
1132
|
+
if "::" in path:
|
|
1133
|
+
# This is a specific test node ID (e.g., test_file.py::test_name[param])
|
|
1134
|
+
# Normalize to remove parametrization - this handles dynamic parameters
|
|
1135
|
+
base_path = path.split("::")[0]
|
|
1136
|
+
test_name_with_params = "::".join(path.split("::")[1:])
|
|
1137
|
+
|
|
1138
|
+
# Normalize the test name (removes [params])
|
|
1139
|
+
normalized_test_name = normalize_test_name(test_name_with_params)
|
|
1140
|
+
normalized_path = f"{base_path}::{normalized_test_name}"
|
|
1141
|
+
|
|
1142
|
+
test_node_ids.add(normalized_path)
|
|
1143
|
+
else:
|
|
1144
|
+
# This is a test file path
|
|
1145
|
+
test_files.add(path)
|
|
1146
|
+
|
|
1147
|
+
# Build pytest command
|
|
1148
|
+
cmd = [
|
|
1149
|
+
sys.executable, "-m", "pytest",
|
|
1150
|
+
"--cov=src",
|
|
1151
|
+
"--cov-context=test",
|
|
1152
|
+
"--cov-append", # Append to existing coverage
|
|
1153
|
+
"--cov-report=", # No report output (we'll combine later)
|
|
1154
|
+
"-v" # Verbose
|
|
1155
|
+
]
|
|
1156
|
+
|
|
1157
|
+
# Add custom pytest args
|
|
1158
|
+
if pytest_args:
|
|
1159
|
+
cmd.extend(pytest_args)
|
|
1160
|
+
|
|
1161
|
+
# Add test files and node IDs
|
|
1162
|
+
cmd.extend(sorted(test_files))
|
|
1163
|
+
cmd.extend(sorted(test_node_ids))
|
|
1164
|
+
|
|
1165
|
+
if verbose:
|
|
1166
|
+
print(f"\n🧪 Running command: {' '.join(cmd)}")
|
|
1167
|
+
if test_node_ids:
|
|
1168
|
+
print(f" Note: Parametrized tests will run all parameter combinations")
|
|
1169
|
+
|
|
1170
|
+
print(f"\n{'='*80}", flush=True)
|
|
1171
|
+
print(f"Running {len(test_paths)} affected test(s)...", flush=True)
|
|
1172
|
+
print(f"{'='*80}\n", flush=True)
|
|
1173
|
+
|
|
1174
|
+
# Run tests
|
|
1175
|
+
result = subprocess.run(
|
|
1176
|
+
cmd,
|
|
1177
|
+
cwd=repo_root
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
return result.returncode == 0
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def main():
|
|
1184
|
+
parser = argparse.ArgumentParser(
|
|
1185
|
+
description="Pintest for pre-commit hook"
|
|
1186
|
+
)
|
|
1187
|
+
parser.add_argument(
|
|
1188
|
+
"--repo-root",
|
|
1189
|
+
type=Path,
|
|
1190
|
+
default=Path.cwd(),
|
|
1191
|
+
help="Repository root directory (default: current directory)"
|
|
1192
|
+
)
|
|
1193
|
+
parser.add_argument(
|
|
1194
|
+
"--target-branch",
|
|
1195
|
+
"--base-branch", # Alias for backward compatibility
|
|
1196
|
+
default="HEAD",
|
|
1197
|
+
help="Target branch to compare against (default: HEAD - current branch)"
|
|
1198
|
+
)
|
|
1199
|
+
parser.add_argument(
|
|
1200
|
+
"--mapping-db",
|
|
1201
|
+
type=Path,
|
|
1202
|
+
default=None,
|
|
1203
|
+
help="Path to test mapping database (default: <repo>/.test_mapping.db)"
|
|
1204
|
+
)
|
|
1205
|
+
parser.add_argument(
|
|
1206
|
+
"--test-dir",
|
|
1207
|
+
type=str,
|
|
1208
|
+
default=None,
|
|
1209
|
+
help="Test directory to collect from (default: auto-detect tests/ or unit_tests/)"
|
|
1210
|
+
)
|
|
1211
|
+
parser.add_argument(
|
|
1212
|
+
"--dry-run",
|
|
1213
|
+
action="store_true",
|
|
1214
|
+
help="Show which tests would run without actually running them"
|
|
1215
|
+
)
|
|
1216
|
+
parser.add_argument(
|
|
1217
|
+
"--skip-coverage-combine",
|
|
1218
|
+
action="store_true",
|
|
1219
|
+
help="Don't combine coverage with existing .coverage file"
|
|
1220
|
+
)
|
|
1221
|
+
parser.add_argument(
|
|
1222
|
+
"--skip-unmapped",
|
|
1223
|
+
action="store_true",
|
|
1224
|
+
help="Skip searching for and running unmapped tests (only run affected mapped tests)"
|
|
1225
|
+
)
|
|
1226
|
+
parser.add_argument(
|
|
1227
|
+
"-v", "--verbose",
|
|
1228
|
+
action="store_true",
|
|
1229
|
+
help="Verbose output"
|
|
1230
|
+
)
|
|
1231
|
+
parser.add_argument(
|
|
1232
|
+
"pytest_args",
|
|
1233
|
+
nargs="*",
|
|
1234
|
+
help="Additional arguments to pass to pytest"
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
args = parser.parse_args()
|
|
1238
|
+
|
|
1239
|
+
repo_root = args.repo_root.resolve()
|
|
1240
|
+
|
|
1241
|
+
# Set up log file for tracking all output
|
|
1242
|
+
log_file = repo_root / ".pre-commit.log"
|
|
1243
|
+
|
|
1244
|
+
# Use TeeOutput to write to both stdout and log file
|
|
1245
|
+
with TeeOutput(log_file):
|
|
1246
|
+
# Default mapping database location
|
|
1247
|
+
if args.mapping_db is None:
|
|
1248
|
+
mapping_db = repo_root / ".test_mapping.db"
|
|
1249
|
+
else:
|
|
1250
|
+
mapping_db = args.mapping_db
|
|
1251
|
+
|
|
1252
|
+
if args.verbose:
|
|
1253
|
+
print(f"🔍 Repository: {repo_root}", flush=True)
|
|
1254
|
+
print(f"🔍 Target branch: {args.target_branch}", flush=True)
|
|
1255
|
+
print(f"🗒 Log file: {log_file}", flush=True)
|
|
1256
|
+
|
|
1257
|
+
# ── Cloud mode detection ─────────────────────────────────
|
|
1258
|
+
cloud_cfg = Config.load()
|
|
1259
|
+
use_cloud = cloud_cfg.is_cloud_enabled and bool(cloud_cfg.cloud.repo_id)
|
|
1260
|
+
cloud_db = None
|
|
1261
|
+
|
|
1262
|
+
if use_cloud:
|
|
1263
|
+
from pintest.cloud_mapping_db import CloudMappingDB
|
|
1264
|
+
cloud_db = CloudMappingDB(cloud_cfg.cloud)
|
|
1265
|
+
mapping_db_obj = cloud_db
|
|
1266
|
+
if args.verbose:
|
|
1267
|
+
print(f"☁️ Mapping Service: Pintest Cloud (repo {cloud_cfg.cloud.repo_id[:8]}...)", flush=True)
|
|
1268
|
+
else:
|
|
1269
|
+
mapping_db_obj = mapping_db
|
|
1270
|
+
if args.verbose:
|
|
1271
|
+
print(f"🖥️ Local mode: {mapping_db}", flush=True)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
# Initialize Git LFS only if using local DB (cloud doesn't need it)
|
|
1275
|
+
if not use_cloud:
|
|
1276
|
+
ensure_git_lfs(repo_root, args.verbose)
|
|
1277
|
+
|
|
1278
|
+
# Check if mapping database exists and is properly initialized (if not using cloud)
|
|
1279
|
+
needs_rebuild = False
|
|
1280
|
+
if not use_cloud:
|
|
1281
|
+
if not mapping_db.exists():
|
|
1282
|
+
needs_rebuild = True
|
|
1283
|
+
else:
|
|
1284
|
+
# Check if database is properly initialized (has tables)
|
|
1285
|
+
try:
|
|
1286
|
+
with TestMappingDBV2(mapping_db) as db:
|
|
1287
|
+
if not db.is_initialized():
|
|
1288
|
+
needs_rebuild = True
|
|
1289
|
+
print(f"⚠️ Database file exists but is not initialized", flush=True)
|
|
1290
|
+
except Exception as e:
|
|
1291
|
+
needs_rebuild = True
|
|
1292
|
+
print(f"⚠️ Database appears corrupted: {e}", flush=True)
|
|
1293
|
+
|
|
1294
|
+
if needs_rebuild:
|
|
1295
|
+
print(f"\n⚠️ Test mapping database not found or invalid: {mapping_db}", flush=True)
|
|
1296
|
+
print("📦 Building mapping database for the first time...", flush=True)
|
|
1297
|
+
print(" This is a one-time operation that may take 30-60 minutes.", flush=True)
|
|
1298
|
+
print(" Future commits will be fast.", flush=True)
|
|
1299
|
+
print(" 💡 Safe to interrupt (Ctrl+C) - it will resume automatically next time.", flush=True)
|
|
1300
|
+
print()
|
|
1301
|
+
|
|
1302
|
+
# Import here to avoid circular dependency
|
|
1303
|
+
from .build_mapping_iterative import build_mapping_iteratively
|
|
1304
|
+
|
|
1305
|
+
result = build_mapping_iteratively(
|
|
1306
|
+
repo_root,
|
|
1307
|
+
mapping_db,
|
|
1308
|
+
args.test_dir,
|
|
1309
|
+
args.verbose
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
if result != 0:
|
|
1313
|
+
print("\n❌ Failed to build mapping database. Commit blocked.")
|
|
1314
|
+
return 1
|
|
1315
|
+
|
|
1316
|
+
print("\n✅ Mapping database built successfully!", flush=True)
|
|
1317
|
+
print()
|
|
1318
|
+
|
|
1319
|
+
# Find affected tests
|
|
1320
|
+
if use_cloud:
|
|
1321
|
+
# Cloud path: send all changed files+lines in one API call
|
|
1322
|
+
git_parser = GitDiffParser(repo_root)
|
|
1323
|
+
diff_output = git_parser.get_diff(args.target_branch)
|
|
1324
|
+
all_changes = git_parser.parse_diff(diff_output)
|
|
1325
|
+
py_changes = git_parser.filter_python_files(all_changes)
|
|
1326
|
+
|
|
1327
|
+
cloud_changes = []
|
|
1328
|
+
for file_path, change in py_changes.items():
|
|
1329
|
+
lines = change.get_all_changed_lines()
|
|
1330
|
+
if lines:
|
|
1331
|
+
cloud_changes.append({"file": file_path, "lines": sorted(lines)})
|
|
1332
|
+
|
|
1333
|
+
if cloud_changes:
|
|
1334
|
+
affected_tests, _ = mapping_db_obj.find_tests_for_changes(cloud_changes)
|
|
1335
|
+
else:
|
|
1336
|
+
affected_tests = set()
|
|
1337
|
+
|
|
1338
|
+
# Always include directly changed test files
|
|
1339
|
+
changed_test_files = git_parser.get_changed_test_files(py_changes)
|
|
1340
|
+
affected_tests |= changed_test_files
|
|
1341
|
+
else:
|
|
1342
|
+
affected_tests = find_affected_tests(
|
|
1343
|
+
repo_root,
|
|
1344
|
+
args.target_branch,
|
|
1345
|
+
mapping_db,
|
|
1346
|
+
args.verbose
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
# Find unmapped tests (tests that exist but aren't in mapping DB yet)
|
|
1350
|
+
unmapped_tests = set()
|
|
1351
|
+
if not args.skip_unmapped:
|
|
1352
|
+
unmapped_tests = find_unmapped_tests(repo_root, mapping_db_obj, args.test_dir, args.verbose)
|
|
1353
|
+
|
|
1354
|
+
# Add unmapped tests to affected tests (they should also run)
|
|
1355
|
+
if unmapped_tests:
|
|
1356
|
+
print(f"\n⚠️ Found {len(unmapped_tests)} unmapped test(s)", flush=True)
|
|
1357
|
+
print(" These tests will run individually to build the coverage mapping", flush=True)
|
|
1358
|
+
affected_tests.update(unmapped_tests)
|
|
1359
|
+
elif args.verbose:
|
|
1360
|
+
print(f"\n💡 Skipping unmapped test search (--skip-unmapped enabled)", flush=True)
|
|
1361
|
+
|
|
1362
|
+
if not affected_tests:
|
|
1363
|
+
print("\n✅ No affected tests found. Commit allowed.", flush=True)
|
|
1364
|
+
return 0
|
|
1365
|
+
|
|
1366
|
+
print(f"\n{'='*80}", flush=True)
|
|
1367
|
+
print(f"Found {len(affected_tests)} affected test(s)", flush=True)
|
|
1368
|
+
if unmapped_tests:
|
|
1369
|
+
print(f" ({len(unmapped_tests)} unmapped + {len(affected_tests) - len(unmapped_tests)} mapped)", flush=True)
|
|
1370
|
+
print(f"{'='*80}", flush=True)
|
|
1371
|
+
|
|
1372
|
+
if args.verbose or args.dry_run:
|
|
1373
|
+
mapped_tests = affected_tests - unmapped_tests
|
|
1374
|
+
if mapped_tests:
|
|
1375
|
+
print("\nMapped tests:")
|
|
1376
|
+
for test in sorted(mapped_tests):
|
|
1377
|
+
print(f" • {test}")
|
|
1378
|
+
if unmapped_tests:
|
|
1379
|
+
print("\nUnmapped tests:")
|
|
1380
|
+
for test in sorted(unmapped_tests):
|
|
1381
|
+
print(f" ⚠️ {test}")
|
|
1382
|
+
|
|
1383
|
+
if args.dry_run:
|
|
1384
|
+
print("\n[DRY RUN] Would run these tests")
|
|
1385
|
+
return 0
|
|
1386
|
+
|
|
1387
|
+
# First, run unmapped tests iteratively to build mapping
|
|
1388
|
+
if unmapped_tests:
|
|
1389
|
+
unmapped_passed, unmapped_count = run_unmapped_tests_iteratively(
|
|
1390
|
+
repo_root,
|
|
1391
|
+
unmapped_tests,
|
|
1392
|
+
mapping_db,
|
|
1393
|
+
args.verbose
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
if not unmapped_passed:
|
|
1397
|
+
print("\n❌ Unmapped tests failed. Commit blocked.", flush=True)
|
|
1398
|
+
print("Fix the failing tests and try again.", flush=True)
|
|
1399
|
+
return 1
|
|
1400
|
+
|
|
1401
|
+
# Then run mapped affected tests (if any remain)
|
|
1402
|
+
mapped_affected = affected_tests - unmapped_tests
|
|
1403
|
+
tests_passed = True
|
|
1404
|
+
|
|
1405
|
+
if mapped_affected:
|
|
1406
|
+
tests_passed = run_tests_with_coverage(
|
|
1407
|
+
repo_root,
|
|
1408
|
+
mapped_affected,
|
|
1409
|
+
args.pytest_args,
|
|
1410
|
+
args.verbose
|
|
1411
|
+
)
|
|
1412
|
+
|
|
1413
|
+
if not tests_passed:
|
|
1414
|
+
print("\n❌ Tests failed. Commit blocked.", flush=True)
|
|
1415
|
+
print("Fix the failing tests and try again.", flush=True)
|
|
1416
|
+
return 1
|
|
1417
|
+
|
|
1418
|
+
# Combine coverage / push to cloud
|
|
1419
|
+
if not args.skip_coverage_combine:
|
|
1420
|
+
new_coverage = repo_root / ".coverage"
|
|
1421
|
+
if new_coverage.exists():
|
|
1422
|
+
if use_cloud:
|
|
1423
|
+
run_stats = {
|
|
1424
|
+
"tests_selected": len(affected_tests),
|
|
1425
|
+
"result": "passed",
|
|
1426
|
+
}
|
|
1427
|
+
cloud_db.push_coverage(new_coverage, run_stats, verbose=args.verbose)
|
|
1428
|
+
else:
|
|
1429
|
+
combine_coverage_files(repo_root, new_coverage)
|
|
1430
|
+
|
|
1431
|
+
# Stage updated mapping database if it was modified
|
|
1432
|
+
# This ensures the updated database is included in the current commit
|
|
1433
|
+
if not use_cloud and mapping_db.exists():
|
|
1434
|
+
# Check if mapping DB was modified (only for local mode)
|
|
1435
|
+
result = subprocess.run(
|
|
1436
|
+
["git", "diff", "--name-only", str(mapping_db.name)],
|
|
1437
|
+
cwd=repo_root,
|
|
1438
|
+
capture_output=True,
|
|
1439
|
+
text=True
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
# Also check if it's untracked
|
|
1443
|
+
status_result = subprocess.run(
|
|
1444
|
+
["git", "status", "--porcelain", str(mapping_db.name)],
|
|
1445
|
+
cwd=repo_root,
|
|
1446
|
+
capture_output=True,
|
|
1447
|
+
text=True
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
if result.stdout.strip() or status_result.stdout.strip():
|
|
1451
|
+
if args.verbose:
|
|
1452
|
+
print(f"\n📦 Staging updated mapping database: {mapping_db.name}")
|
|
1453
|
+
|
|
1454
|
+
# Add to staging area
|
|
1455
|
+
add_result = subprocess.run(
|
|
1456
|
+
["git", "add", str(mapping_db.name)],
|
|
1457
|
+
cwd=repo_root,
|
|
1458
|
+
capture_output=True,
|
|
1459
|
+
text=True
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
if add_result.returncode == 0:
|
|
1463
|
+
print(f"✓ Updated mapping database will be included in this commit")
|
|
1464
|
+
elif args.verbose:
|
|
1465
|
+
print(f"⚠️ Could not stage mapping database: {add_result.stderr}")
|
|
1466
|
+
|
|
1467
|
+
print("\n✅ All affected tests passed. Commit allowed.", flush=True)
|
|
1468
|
+
return 0
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
if __name__ == "__main__":
|
|
1472
|
+
sys.exit(main())
|