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.
@@ -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())