119 lines
4.4 KiB
Python
119 lines
4.4 KiB
Python
|
|
"""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
|