commit_hygiene.py
1 #!/usr/bin/env python3 2 """ 3 Commit Hygiene Checker - Enforce commit-as-you-go protocol. 4 5 A4 Alignment: Uncommitted work is ruin exposure. 6 Uncommitted code doesn't compound across instances. 7 8 Usage: 9 python3 scripts/commit_hygiene.py # Full report 10 python3 scripts/commit_hygiene.py --quick # Just status line 11 python3 scripts/commit_hygiene.py --block # Exit 1 if violations 12 """ 13 14 import subprocess 15 import sys 16 from datetime import datetime 17 from pathlib import Path 18 19 # Paths that MUST be committed promptly (code files) 20 CRITICAL_PATHS = [ 21 "core/", 22 "scripts/", 23 "hooks/", 24 "keet-cli/", 25 "docs/", 26 ] 27 28 # Paths that can batch (session state) 29 BATCHABLE_PATHS = [ 30 "sessions/", 31 ".obsidian/", 32 ] 33 34 # Always ignore 35 IGNORE_PATTERNS = [ 36 "__pycache__", 37 ".pyc", 38 "RESONANCE-ALERTS/", 39 ".DS_Store", 40 ] 41 42 def get_uncommitted_files(): 43 """Get list of uncommitted files with their status.""" 44 result = subprocess.run( 45 ["git", "status", "--porcelain"], 46 capture_output=True, 47 text=True, 48 cwd=Path(__file__).parent.parent 49 ) 50 files = [] 51 for line in result.stdout.strip().split("\n"): 52 if not line: 53 continue 54 status = line[:2] 55 filepath = line[3:] 56 files.append((status, filepath)) 57 return files 58 59 def categorize_files(files): 60 """Categorize files into critical vs batchable.""" 61 critical = [] 62 batchable = [] 63 ignored = [] 64 65 for status, filepath in files: 66 # Check if ignored 67 if any(pattern in filepath for pattern in IGNORE_PATTERNS): 68 ignored.append((status, filepath)) 69 continue 70 71 # Check if critical 72 is_critical = any(filepath.startswith(p) for p in CRITICAL_PATHS) 73 is_batchable = any(filepath.startswith(p) for p in BATCHABLE_PATHS) 74 75 if is_critical and not is_batchable: 76 critical.append((status, filepath)) 77 else: 78 batchable.append((status, filepath)) 79 80 return critical, batchable, ignored 81 82 def get_last_commit_time(): 83 """Get time since last commit.""" 84 result = subprocess.run( 85 ["git", "log", "-1", "--format=%ct"], 86 capture_output=True, 87 text=True, 88 cwd=Path(__file__).parent.parent 89 ) 90 if result.stdout.strip(): 91 timestamp = int(result.stdout.strip()) 92 last_commit = datetime.fromtimestamp(timestamp) 93 delta = datetime.now() - last_commit 94 return delta.total_seconds() / 60 # minutes 95 return None 96 97 def main(): 98 quick = "--quick" in sys.argv 99 block = "--block" in sys.argv 100 101 files = get_uncommitted_files() 102 critical, batchable, ignored = categorize_files(files) 103 minutes_since_commit = get_last_commit_time() 104 105 # Determine status 106 violations = [] 107 108 if len(critical) > 0: 109 violations.append(f"{len(critical)} critical files uncommitted") 110 111 if len(critical) > 10: 112 violations.append("CRITICAL: >10 code files uncommitted") 113 114 if minutes_since_commit and minutes_since_commit > 60: 115 violations.append(f"STALE: {int(minutes_since_commit)}min since last commit") 116 117 # Quick mode - just status line 118 if quick: 119 if violations: 120 print(f"⚠️ COMMIT HYGIENE: {'; '.join(violations)}") 121 if block: 122 sys.exit(1) 123 else: 124 print(f"✓ Commit hygiene OK ({len(batchable)} batchable files pending)") 125 return 126 127 # Full report 128 print("=" * 60) 129 print("COMMIT HYGIENE REPORT") 130 print("=" * 60) 131 print() 132 133 if minutes_since_commit: 134 print(f"Time since last commit: {int(minutes_since_commit)} minutes") 135 print() 136 137 # Critical files 138 print(f"CRITICAL FILES (must commit): {len(critical)}") 139 print("-" * 40) 140 if critical: 141 for status, filepath in critical[:20]: 142 status_str = {"??": "NEW", " M": "MOD", "M ": "STG", "A ": "ADD"}.get(status, status) 143 print(f" [{status_str}] {filepath}") 144 if len(critical) > 20: 145 print(f" ... and {len(critical) - 20} more") 146 else: 147 print(" (none - good!)") 148 print() 149 150 # Batchable files 151 print(f"BATCHABLE FILES (can defer): {len(batchable)}") 152 print("-" * 40) 153 if batchable: 154 # Group by directory 155 dirs = {} 156 for status, filepath in batchable: 157 dir_name = filepath.split("/")[0] if "/" in filepath else "(root)" 158 dirs[dir_name] = dirs.get(dir_name, 0) + 1 159 for dir_name, count in sorted(dirs.items(), key=lambda x: -x[1]): 160 print(f" {dir_name}/: {count} files") 161 else: 162 print(" (none)") 163 print() 164 165 # Ignored 166 print(f"IGNORED: {len(ignored)} files") 167 print() 168 169 # Violations 170 if violations: 171 print("⚠️ VIOLATIONS:") 172 print("-" * 40) 173 for v in violations: 174 print(f" • {v}") 175 print() 176 print("ACTION REQUIRED: Run 'git add' and 'git commit' for critical files") 177 print() 178 if block: 179 sys.exit(1) 180 else: 181 print("✓ COMMIT HYGIENE: OK") 182 print() 183 184 # Quick commands 185 if critical: 186 print("SUGGESTED COMMANDS:") 187 print("-" * 40) 188 # Group critical files by type 189 core_files = [f for _, f in critical if f.startswith("core/")] 190 script_files = [f for _, f in critical if f.startswith("scripts/")] 191 other_files = [f for _, f in critical if not f.startswith("core/") and not f.startswith("scripts/")] 192 193 if core_files: 194 print(f" git add core/") 195 if script_files: 196 print(f" git add scripts/") 197 if other_files: 198 for _, f in other_files[:5]: 199 print(f" git add {f}") 200 print(' git commit -m "type(scope): description"') 201 print() 202 203 if __name__ == "__main__": 204 main()