"""repo_sync.py — T04 Push Seal: closed-loop git sync for consistency runs. The **push-seal invariant** --------------------------- Every ``fix_repo()`` run that creates commits must push them to remote before returning. This guarantees the next run starts with ``local ≡ remote``, making the 15-minute timer idempotent and preventing the open-loop commit pile-up that occurs when automated writebacks accumulate without being pushed. Three checks enforce the invariant end-to-end: C-16 repo-behind-remote local is behind remote; skip writes, pull first C-17 repo-ahead-push-failed local has unpushed commits and push failed; skip writes T04 push-seal at end of every fix run, push all commits made this run Stable state the timer converges to ------------------------------------- repo clean AND local ≡ remote → ``_report_needs_action`` returns False → repo skipped entirely → no git activity, no divergence Public API ---------- pull_ff(repo_path) git pull --ff-only → (ok: bool, message: str) push_ff(repo_path) git push --ff-only → (ok: bool, message: str) count_remote_ahead(path) commits remote has that local lacks (C-16 input) count_local_ahead(path) commits local has that remote lacks (C-17 / T04 input) All functions are **best-effort**: errors return 0 / (False, "…") rather than raising, so the consistency engine is never blocked by transient network issues. The caller decides whether a 0 / False result is a problem. """ from __future__ import annotations import subprocess def pull_ff(repo_path: str) -> tuple[bool, str]: """Run ``git pull --ff-only`` on *repo_path*. Returns ``(success, message)`` describing the outcome. Never raises. """ try: result = subprocess.run( ["git", "-C", repo_path, "pull", "--ff-only"], capture_output=True, text=True, timeout=30, ) if result.returncode == 0: return True, result.stdout.strip() or "already up to date" return False, result.stderr.strip() or "pull failed" except Exception as exc: return False, str(exc) def push_ff(repo_path: str) -> tuple[bool, str]: """Run ``git push`` on *repo_path*. ``git push`` already rejects non-fast-forward updates by default (the remote refuses them), so no ``--force`` flag is needed or used. The function name signals the *semantic* guarantee — we never force-push — not a literal CLI flag. Returns ``(success, message)`` describing the outcome. Never raises. """ try: result = subprocess.run( ["git", "-C", repo_path, "push"], capture_output=True, text=True, timeout=30, ) if result.returncode == 0: return True, result.stdout.strip() or "pushed" return False, result.stderr.strip() or "push failed" except Exception as exc: return False, str(exc) def count_remote_ahead(repo_path: str) -> int: """Return how many commits remote has that local lacks (the *behind* count). Runs ``git fetch --quiet origin`` first so the result reflects the actual current remote state, not a stale tracking branch. Returns 0 on any error (network down, no upstream configured, etc.) so C-16 is never spuriously triggered by connectivity issues. """ try: subprocess.run( ["git", "-C", repo_path, "fetch", "--quiet", "origin"], capture_output=True, timeout=15, ) result = subprocess.run( ["git", "-C", repo_path, "rev-list", "--count", "HEAD..@{u}"], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: return 0 return int(result.stdout.strip() or "0") except Exception: return 0 def count_local_ahead(repo_path: str) -> int: """Return how many commits local has that remote lacks (the *ahead* count). Uses the already-fetched tracking branch — call after ``count_remote_ahead`` (which runs fetch) for the most accurate result, or accept a one-run lag. Returns 0 on any error so C-17 is never spuriously triggered. """ try: result = subprocess.run( ["git", "-C", repo_path, "rev-list", "--count", "@{u}..HEAD"], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: return 0 return int(result.stdout.strip() or "0") except Exception: return 0