generated from coulomb/repo-seed
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>
436 lines
16 KiB
Python
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
|