Files
state-hub/tests/test_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

436 lines
16 KiB
Python

"""Tests for repo_sync.py — T04 Push Seal.
Verifies the push-seal invariant and all four git lifecycle primitives.
The invariant under test
------------------------
Every fix_repo() run that creates commits must push before returning, so the
next run starts with ``local ≡ remote``. Three observable properties flow
from this:
1. After a successful push, count_local_ahead returns 0.
2. Before any local commits, count_local_ahead returns 0.
3. When a push fails (diverged), C-17 skips further writes so local does
not accumulate additional commits on top of the backlog.
Test anatomy
------------
All tests use real git repos via tmp_path — no mocks, no subprocess patches.
The ``git_pair`` fixture creates a bare remote + one clone wired with an
initial pushed commit, matching the setup used in production repos.
A ``second_clone`` helper creates a second independent worker clone for
scenarios that require concurrent pushes (divergence simulation).
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from repo_sync import count_local_ahead, count_remote_ahead, pull_ff, push_ff
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _git(args: list[str], cwd: str | None = None) -> str:
"""Run a git command, return stdout. Raises on non-zero exit."""
cmd = ["git"] + args
if cwd:
cmd = ["git", "-C", cwd] + args[1:] if args[0] == "-C" else ["git", "-C", cwd] + args
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.strip()
def _commit(repo: Path, message: str = "auto") -> None:
"""Make an empty commit in *repo*."""
subprocess.run(
["git", "-C", str(repo), "commit", "--allow-empty", "-m", message],
capture_output=True, check=True,
)
def _push(repo: Path) -> None:
subprocess.run(
["git", "-C", str(repo), "push"],
capture_output=True, check=True,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def git_pair(tmp_path):
"""Bare remote + one clone with an initial pushed commit.
Returns (bare: Path, clone: Path).
"""
bare = tmp_path / "bare.git"
clone = tmp_path / "clone"
bare.mkdir()
subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True, check=True)
subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True, check=True)
subprocess.run(
["git", "-C", str(clone), "config", "user.email", "test@test.com"],
capture_output=True, check=True,
)
subprocess.run(
["git", "-C", str(clone), "config", "user.name", "Test"],
capture_output=True, check=True,
)
_commit(clone, "init")
_push(clone)
return bare, clone
def _make_second_clone(bare: Path, tmp_path: Path) -> Path:
"""Clone bare into a second independent working directory."""
worker = tmp_path / "worker"
subprocess.run(["git", "clone", str(bare), str(worker)], capture_output=True, check=True)
subprocess.run(
["git", "-C", str(worker), "config", "user.email", "worker@test.com"],
capture_output=True, check=True,
)
subprocess.run(
["git", "-C", str(worker), "config", "user.name", "Worker"],
capture_output=True, check=True,
)
return worker
# ---------------------------------------------------------------------------
# Error resilience — all functions must never raise
# ---------------------------------------------------------------------------
class TestErrorResilience:
"""All four functions must return safe defaults for bad inputs."""
def test_count_local_ahead_nonexistent_path(self):
assert count_local_ahead("/nonexistent/xyz") == 0
def test_count_remote_ahead_nonexistent_path(self):
assert count_remote_ahead("/nonexistent/xyz") == 0
def test_pull_ff_nonexistent_path(self):
ok, msg = pull_ff("/nonexistent/xyz")
assert ok is False
assert msg # some error message
def test_push_ff_nonexistent_path(self):
ok, msg = push_ff("/nonexistent/xyz")
assert ok is False
assert msg
def test_count_local_ahead_non_git_dir(self, tmp_path):
assert count_local_ahead(str(tmp_path)) == 0
def test_count_remote_ahead_non_git_dir(self, tmp_path):
assert count_remote_ahead(str(tmp_path)) == 0
def test_count_local_ahead_no_upstream(self, tmp_path):
"""Local-only repo with no remote → no upstream ref → returns 0."""
subprocess.run(["git", "init", str(tmp_path)], capture_output=True, check=True)
subprocess.run(
["git", "-C", str(tmp_path), "config", "user.email", "t@t.com"],
capture_output=True,
)
subprocess.run(
["git", "-C", str(tmp_path), "config", "user.name", "T"],
capture_output=True,
)
_commit(tmp_path, "local only")
assert count_local_ahead(str(tmp_path)) == 0
def test_count_remote_ahead_no_upstream(self, tmp_path):
subprocess.run(["git", "init", str(tmp_path)], capture_output=True, check=True)
assert count_remote_ahead(str(tmp_path)) == 0
# ---------------------------------------------------------------------------
# count_local_ahead — ahead count
# ---------------------------------------------------------------------------
class TestCountLocalAhead:
"""count_local_ahead measures unpushed local commits."""
def test_zero_when_in_sync(self, git_pair):
_, clone = git_pair
assert count_local_ahead(str(clone)) == 0
def test_one_after_one_local_commit(self, git_pair):
_, clone = git_pair
_commit(clone, "local change")
assert count_local_ahead(str(clone)) == 1
def test_three_after_three_local_commits(self, git_pair):
_, clone = git_pair
for i in range(3):
_commit(clone, f"local {i}")
assert count_local_ahead(str(clone)) == 3
def test_zero_after_push(self, git_pair):
_, clone = git_pair
_commit(clone, "to push")
assert count_local_ahead(str(clone)) == 1
_push(clone)
assert count_local_ahead(str(clone)) == 0
# ---------------------------------------------------------------------------
# count_remote_ahead — behind count
# ---------------------------------------------------------------------------
class TestCountRemoteAhead:
"""count_remote_ahead measures how far behind local is from remote."""
def test_zero_when_in_sync(self, git_pair):
_, clone = git_pair
assert count_remote_ahead(str(clone)) == 0
def test_zero_when_local_is_ahead(self, git_pair):
"""Local having unpushed commits does NOT count as being behind."""
_, clone = git_pair
_commit(clone, "local only")
assert count_remote_ahead(str(clone)) == 0
def test_one_when_remote_has_one_new_commit(self, git_pair, tmp_path):
bare, clone = git_pair
worker = _make_second_clone(bare, tmp_path)
_commit(worker, "remote work")
_push(worker)
assert count_remote_ahead(str(clone)) == 1
def test_three_when_remote_has_three_new_commits(self, git_pair, tmp_path):
bare, clone = git_pair
worker = _make_second_clone(bare, tmp_path)
for i in range(3):
_commit(worker, f"remote {i}")
_push(worker)
assert count_remote_ahead(str(clone)) == 3
def test_nonzero_when_diverged(self, git_pair, tmp_path):
"""Diverged repo: remote has commits local lacks — counted as behind."""
bare, clone = git_pair
worker = _make_second_clone(bare, tmp_path)
_commit(worker, "remote fork")
_push(worker)
_commit(clone, "local fork") # clone diverges
assert count_remote_ahead(str(clone)) > 0
# ---------------------------------------------------------------------------
# push_ff — T04 push seal
# ---------------------------------------------------------------------------
class TestPushFf:
"""push_ff enforces the push-seal invariant."""
def test_succeeds_when_local_is_ahead(self, git_pair):
_, clone = git_pair
_commit(clone, "to seal")
ok, msg = push_ff(str(clone))
assert ok is True
assert msg
def test_succeeds_when_already_in_sync(self, git_pair):
"""Push on an already-in-sync repo is a no-op and still returns True."""
_, clone = git_pair
ok, msg = push_ff(str(clone))
assert ok is True
def test_push_seal_invariant(self, git_pair):
"""After a successful push, count_local_ahead must be 0."""
_, clone = git_pair
for i in range(3):
_commit(clone, f"writeback {i}")
assert count_local_ahead(str(clone)) == 3
ok, _ = push_ff(str(clone))
assert ok is True
assert count_local_ahead(str(clone)) == 0 # invariant holds
def test_push_seal_idempotent(self, git_pair):
"""Calling push_ff twice produces the same end state."""
_, clone = git_pair
_commit(clone, "once")
push_ff(str(clone))
ok, _ = push_ff(str(clone))
assert ok is True
assert count_local_ahead(str(clone)) == 0
def test_fails_when_diverged(self, git_pair, tmp_path):
"""push_ff fails (non-fast-forward) when remote and local have forked."""
bare, clone = git_pair
worker = _make_second_clone(bare, tmp_path)
_commit(worker, "remote fork")
_push(worker)
_commit(clone, "local fork") # clone diverges
ok, msg = push_ff(str(clone))
assert ok is False
assert msg # git explains the rejection
def test_c17_scenario(self, git_pair, tmp_path):
"""C-17: local has unpushed commits from a prior run; push fails (diverged).
The guard detects count_local_ahead > 0, tries push_ff, gets False,
and must skip all further writes for this run.
"""
bare, clone = git_pair
worker = _make_second_clone(bare, tmp_path)
# Simulate prior run: made commits but never pushed
_commit(clone, "prior auto-commit 1")
_commit(clone, "prior auto-commit 2")
# Meanwhile remote moved on
_commit(worker, "remote progress")
_push(worker)
# Now clone is diverged: 2 ahead, 1 behind
ahead = count_local_ahead(str(clone))
behind = count_remote_ahead(str(clone))
assert ahead == 2
assert behind == 1
# Push attempt fails (non-ff)
ok, _ = push_ff(str(clone))
assert ok is False
# End state: still diverged (guard did not make it worse)
assert count_local_ahead(str(clone)) == 2
# ---------------------------------------------------------------------------
# pull_ff — T02 pull gate
# ---------------------------------------------------------------------------
class TestPullFf:
"""pull_ff enables the T02 pull gate: pull before fixing when behind."""
def test_succeeds_when_already_up_to_date(self, git_pair):
_, clone = git_pair
ok, msg = pull_ff(str(clone))
assert ok is True
def test_pulls_new_remote_commits(self, git_pair, tmp_path):
bare, clone = git_pair
worker = _make_second_clone(bare, tmp_path)
_commit(worker, "remote work")
_push(worker)
ok, _ = pull_ff(str(clone))
assert ok is True
log = subprocess.run(
["git", "-C", str(clone), "log", "--oneline", "-1"],
capture_output=True, text=True,
).stdout
assert "remote work" in log
def test_pull_resolves_behind_state(self, git_pair, tmp_path):
"""After pull_ff, count_remote_ahead should drop to 0."""
bare, clone = git_pair
worker = _make_second_clone(bare, tmp_path)
_commit(worker, "new remote")
_push(worker)
assert count_remote_ahead(str(clone)) == 1
pull_ff(str(clone))
assert count_remote_ahead(str(clone)) == 0
def test_fails_when_diverged(self, git_pair, tmp_path):
"""pull_ff (ff-only) fails when local and remote have forked."""
bare, clone = git_pair
worker = _make_second_clone(bare, tmp_path)
_commit(worker, "remote fork")
_push(worker)
_commit(clone, "local fork")
ok, msg = pull_ff(str(clone))
assert ok is False
assert msg
def test_returns_false_no_remote(self, tmp_path):
"""Local-only repo with no remote configured."""
subprocess.run(["git", "init", str(tmp_path)], capture_output=True, check=True)
subprocess.run(
["git", "-C", str(tmp_path), "config", "user.email", "t@t.com"],
capture_output=True,
)
subprocess.run(
["git", "-C", str(tmp_path), "config", "user.name", "T"],
capture_output=True,
)
_commit(tmp_path)
ok, _ = pull_ff(str(tmp_path))
assert ok is False
# ---------------------------------------------------------------------------
# End-to-end: push seal closes the loop
# ---------------------------------------------------------------------------
class TestPushSealLoop:
"""Integration tests verifying the full timer-loop stability property.
These mirror the exact sequence the custodian-sync.service runs:
1. Detect state (count_remote_ahead, count_local_ahead)
2. Pull if behind
3. Make writeback commits
4. Push (seal)
5. Next run sees local ≡ remote → skipped clean
"""
def test_full_cycle_leaves_repo_clean(self, git_pair):
"""Simulate one complete fix run: commit + push → next check is clean."""
_, clone = git_pair
# Simulate writeback commits made during fix_repo()
_commit(clone, "chore(consistency): sync task status from DB [auto]")
assert count_local_ahead(str(clone)) == 1
# Push seal
ok, _ = push_ff(str(clone))
assert ok is True
# Next timer run: no behind, no ahead → clean
assert count_remote_ahead(str(clone)) == 0
assert count_local_ahead(str(clone)) == 0
def test_multiple_writebacks_sealed_in_one_push(self, git_pair):
"""Multiple commits in one run are all sealed by a single push."""
_, clone = git_pair
for i in range(5):
_commit(clone, f"writeback {i}")
assert count_local_ahead(str(clone)) == 5
push_ff(str(clone))
assert count_local_ahead(str(clone)) == 0
assert count_remote_ahead(str(clone)) == 0
def test_no_commit_means_no_push_needed(self, git_pair):
"""If fix_repo() made no commits, push is a no-op and state is unchanged."""
_, clone = git_pair
assert count_local_ahead(str(clone)) == 0
ok, _ = push_ff(str(clone))
assert ok is True
assert count_local_ahead(str(clone)) == 0
def test_runaway_prevention_on_failed_push(self, git_pair, tmp_path):
"""Diverged repo: C-17 fires, skips writes, push backlog does not grow."""
bare, clone = git_pair
worker = _make_second_clone(bare, tmp_path)
# Prior failed push left two commits locally
_commit(clone, "auto 1")
_commit(clone, "auto 2")
# Remote progressed independently
_commit(worker, "remote progress")
_push(worker)
ahead_before = count_local_ahead(str(clone))
# C-17 guard: try to push backlog, fail
ok, _ = push_ff(str(clone))
assert ok is False
# Guard must not have made things worse
assert count_local_ahead(str(clone)) == ahead_before