generated from coulomb/repo-seed
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>
This commit is contained in:
@@ -37,6 +37,8 @@ from consistency_check import (
|
||||
render_text,
|
||||
report_to_dict,
|
||||
)
|
||||
# _detect_behind_remote and _git_pull are re-exported from consistency_check
|
||||
# for backward compat; their canonical implementations live in repo_sync.py.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -675,3 +677,25 @@ class TestReportNeedsAction:
|
||||
|
||||
def test_background_checks_constant_contains_c08(self):
|
||||
assert "C-08" in _BACKGROUND_CHECKS
|
||||
|
||||
# --- ahead_of_remote (T04 push-seal integration) ---
|
||||
|
||||
def test_ahead_of_remote_triggers_action(self):
|
||||
"""Unpushed commits from a prior failed push must re-trigger a fix cycle."""
|
||||
r = self._make_report([])
|
||||
assert _report_needs_action(r, behind_remote=False, ahead_of_remote=3) is True
|
||||
|
||||
def test_zero_ahead_clean_is_skipped(self):
|
||||
"""Fully in-sync repo with no issues is skipped."""
|
||||
r = self._make_report([])
|
||||
assert _report_needs_action(r, behind_remote=False, ahead_of_remote=0) is False
|
||||
|
||||
def test_ahead_default_is_zero(self):
|
||||
"""Default value of ahead_of_remote=0 preserves backward compat."""
|
||||
r = self._make_report([])
|
||||
assert _report_needs_action(r, behind_remote=False) is False
|
||||
|
||||
def test_both_behind_and_ahead_triggers(self):
|
||||
"""Diverged repo (both behind and ahead) needs action."""
|
||||
r = self._make_report([])
|
||||
assert _report_needs_action(r, behind_remote=True, ahead_of_remote=5) is True
|
||||
|
||||
435
tests/test_repo_sync.py
Normal file
435
tests/test_repo_sync.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user