"""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