Files
state-hub/scripts/repo_sync.py
tegwick 6cbf2d2c56 feat(consistency): T04 push seal — closed-loop writeback for automated commits
Root cause of the 501-commit pile-up in inter-hub: fix_repo() created
git commits (brief updates, T03 writebacks) but never pushed them, so
the 15-minute timer accumulated local commits indefinitely. Once real
development landed on remote the repos diverged with no self-healing path.

Changes
-------
repo_sync.py (new module)
  Extracts all git lifecycle primitives: pull_ff, push_ff,
  count_remote_ahead (C-16 input), count_local_ahead (C-17/T04 input).
  Module docstring documents the push-seal invariant and stable state.

consistency_check.py
  - Imports primitives from repo_sync; thin _detect_behind_remote wrapper
    preserves backward compat for existing callers and tests.
  - C-17 backlog guard: if local has unpushed commits from a prior failed
    push, retry before making more; skip all writes if push still fails.
  - T04 push seal: unconditional push_ff() at end of every fix_repo() run.
  - _report_needs_action: ahead_of_remote param so repos with unpushed
    backlogs are not silently skipped as "clean" by fix_all_remote().
  - Domain-slug fallback: brief no longer degrades to "(unknown)" when all
    workplans are completed — falls back to any workstream for domain context.
  - Service switched from --all --fix to --remote --all (pulls before
    fixing, skips already-clean repos).

push-seal.md (new)
  Capability documentation: the problem, the invariant, all three checks
  (C-16/C-17/T04), stable-state description, API reference, and test map.

test_repo_sync.py (new, 32 tests)
  Full coverage of all four primitives via real git repos (tmp_path).
  Includes C-17 scenario, push-seal invariant, and four end-to-end
  loop-stability tests.

test_consistency_check.py
  Four new _report_needs_action cases for the ahead_of_remote parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:43:40 +02:00

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