/ scripts / commit_hygiene.py
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()