#!/usr/bin/env python3 """ hooks/pre_commit_xlsx.py Pre-commit hook: ensures protected XLSX files are committed headers-only. For each protected XLSX file that is currently staged, this script: 1. Loads the file with openpyxl. 2. Deletes all data rows (keeps only row 1 — the header) on every sheet. 3. Writes the stripped copy back to disk. 4. Re-stages the stripped file with git add. If openpyxl is not available or the strip operation fails, the commit is blocked with a descriptive error message. Setup (run once per clone): pip install pre-commit pre-commit install """ import os import shutil import subprocess import sys import tempfile # XLSX files that must never be committed with data rows. PROTECTED_XLSX: list[str] = [ "Missing Pub Song Correction Master Lookup.xlsx", "Publishing Admin Deal Tracker_Official.xlsx", "Publishing Admin Deal Tracker_TD0124.xlsx", "Publishing Admin Deal Tracker_TD0324.xlsx", ] def get_staged_files() -> list[str]: """Return a list of file paths currently staged for commit.""" result = subprocess.run( ["git", "diff", "--cached", "--name-only"], capture_output=True, text=True, check=True, ) return [line.strip() for line in result.stdout.splitlines() if line.strip()] def strip_to_headers(path: str) -> str: """Delete all data rows (keep header row 1 only) and return path to stripped temp file.""" try: import openpyxl except ImportError: raise RuntimeError("openpyxl is not installed. Run: pip install openpyxl") try: wb = openpyxl.load_workbook(path) except Exception as exc: raise RuntimeError(f"Could not open {path}: {exc}") from exc for sheet in wb.worksheets: if sheet.max_row > 1: sheet.delete_rows(2, sheet.max_row - 1) tmp_fd, tmp_path = tempfile.mkstemp( suffix=".xlsx", dir=os.path.dirname(os.path.abspath(path)) ) os.close(tmp_fd) try: wb.save(tmp_path) except Exception as exc: os.unlink(tmp_path) raise RuntimeError(f"Could not save stripped copy of {path}: {exc}") from exc return tmp_path def restage(original_path: str, stripped_path: str) -> None: """Replace the on-disk file with the stripped copy and re-stage it.""" shutil.move(stripped_path, original_path) subprocess.run(["git", "add", original_path], check=True) def main() -> int: try: staged = get_staged_files() except subprocess.CalledProcessError as exc: print( f"[pre-commit] ERROR: could not list staged files: {exc}", file=sys.stderr ) return 1 repo_root = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=True, ).stdout.strip() for staged_path in staged: filename = os.path.basename(staged_path) if filename not in PROTECTED_XLSX: continue abs_path = os.path.join(repo_root, staged_path) if not os.path.isfile(abs_path): continue # deleted file — nothing to strip try: stripped = strip_to_headers(abs_path) except RuntimeError as exc: print(f"[pre-commit] ERROR: {exc}", file=sys.stderr) print( "[pre-commit] Commit blocked to protect against committing XLSX data rows.", file=sys.stderr, ) return 1 restage(abs_path, stripped) print( f"[pre-commit] Stripped data rows from {staged_path} (headers only committed)." ) return 0 if __name__ == "__main__": sys.exit(main())