generated from coulomb/repo-seed
feat(consistency): distributed multi-machine safety (CUST-WP-0026)
T01 — No-regress rule (C-15): fix-consistency now detects when a DB task status is ahead of the workplan file (e.g. marked done on CoulombCore) and emits C-15 WARN instead of regressing the DB back to the stale file value. STATUS_ORDER ranking: todo(0) < in_progress/blocked(1) < done/cancelled(2). T02 — Pull gate (C-16): fix_repo runs git fetch + rev-parse at the start of every --fix run. If the local repo is behind its remote tracking branch, all write operations are skipped and C-16 WARN is emitted. Best-effort: offline/no-remote silently skips the check. T03 — DB→file writeback: C-15 fix path patches the status field in the matching task block and git-commits the change with a standard message. --no-writeback flag disables writeback while keeping T01/T02 active. T04 — CLAUDE.md + session-protocol.template updated with new guidance, C-15/C-16 semantics, and fix-consistency-remote recommendation. T05 — Makefile: fix-consistency-remote pulls then fixes in one step. 16 new tests; 155 passed total. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,9 @@ from consistency_check import (
|
||||
ConsistencyReport,
|
||||
Issue,
|
||||
FILE_TO_DB_WORKSTREAM_STATUS,
|
||||
STATUS_ORDER,
|
||||
_detect_behind_remote,
|
||||
_patch_task_status_in_file,
|
||||
get_tasks_from_workplan,
|
||||
normalise_workstream_status,
|
||||
parse_frontmatter,
|
||||
@@ -371,3 +374,169 @@ class TestNormaliseWorkstreamStatus:
|
||||
def test_c04_real_drift_still_detected(self):
|
||||
"""done (file) vs active (DB) IS real drift and must be detected."""
|
||||
assert normalise_workstream_status("done") != normalise_workstream_status("active")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STATUS_ORDER / no-regress rule (T01 / C-15)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStatusOrder:
|
||||
"""STATUS_ORDER drives the no-regress rule: DB-ahead wins, file-ahead syncs."""
|
||||
|
||||
def test_todo_is_lowest(self):
|
||||
assert STATUS_ORDER["todo"] == 0
|
||||
|
||||
def test_done_and_cancelled_are_highest(self):
|
||||
assert STATUS_ORDER["done"] == STATUS_ORDER["cancelled"] == 2
|
||||
|
||||
def test_in_progress_and_blocked_are_mid(self):
|
||||
assert STATUS_ORDER["in_progress"] == STATUS_ORDER["blocked"] == 1
|
||||
|
||||
def test_db_ahead_detected(self):
|
||||
"""done (DB) vs todo (file) — DB is ahead."""
|
||||
assert STATUS_ORDER["done"] > STATUS_ORDER["todo"]
|
||||
|
||||
def test_file_ahead_detected(self):
|
||||
"""done (file) vs todo (DB) — file is ahead, should sync."""
|
||||
assert STATUS_ORDER["todo"] < STATUS_ORDER["done"]
|
||||
|
||||
def test_same_rank_treated_as_db_ahead(self):
|
||||
"""in_progress (DB) vs blocked (file) — same rank, no regression."""
|
||||
assert STATUS_ORDER["in_progress"] >= STATUS_ORDER["blocked"]
|
||||
|
||||
def test_todo_to_done_is_regression(self):
|
||||
"""Applying file=todo to DB=done would be a regression."""
|
||||
db_rank = STATUS_ORDER["done"]
|
||||
file_rank = STATUS_ORDER["todo"]
|
||||
assert db_rank >= file_rank # → should emit C-15, not C-10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_behind_remote (T02 / C-16)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetectBehindRemote:
|
||||
"""_detect_behind_remote must return False on any error (best-effort)."""
|
||||
|
||||
def test_returns_false_for_nonexistent_path(self):
|
||||
assert _detect_behind_remote("/nonexistent/path/xyz") is False
|
||||
|
||||
def test_returns_false_for_non_git_dir(self, tmp_path):
|
||||
assert _detect_behind_remote(str(tmp_path)) is False
|
||||
|
||||
def test_returns_false_for_repo_without_remote(self, tmp_path):
|
||||
"""A local-only repo with no remote tracking branch → not behind."""
|
||||
import subprocess
|
||||
subprocess.run(["git", "init", str(tmp_path)], capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(tmp_path), "commit", "--allow-empty", "-m", "init"],
|
||||
capture_output=True,
|
||||
)
|
||||
# No remote configured → rev-parse @{u} fails → best-effort returns False
|
||||
assert _detect_behind_remote(str(tmp_path)) is False
|
||||
|
||||
def test_returns_false_when_up_to_date(self, tmp_path):
|
||||
"""Clone from a local bare repo, no new commits → not behind."""
|
||||
import subprocess
|
||||
bare = tmp_path / "bare.git"
|
||||
clone = tmp_path / "clone"
|
||||
bare.mkdir()
|
||||
subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True)
|
||||
subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(clone), "commit", "--allow-empty", "-m", "init"],
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["git", "-C", str(clone), "push", "origin", "HEAD"], capture_output=True)
|
||||
assert _detect_behind_remote(str(clone)) is False
|
||||
|
||||
def test_returns_true_when_behind(self, tmp_path):
|
||||
"""Remote has a commit the clone doesn't → clone is behind."""
|
||||
import subprocess
|
||||
bare = tmp_path / "bare.git"
|
||||
clone = tmp_path / "clone"
|
||||
bare.mkdir()
|
||||
subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True)
|
||||
subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True)
|
||||
# Initial commit pushed so there's an upstream ref
|
||||
subprocess.run(
|
||||
["git", "-C", str(clone), "commit", "--allow-empty", "-m", "init"],
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["git", "-C", str(clone), "push", "origin", "HEAD:main"], capture_output=True)
|
||||
|
||||
# Add a commit directly in the bare repo (simulates remote-only progress)
|
||||
work = tmp_path / "work"
|
||||
subprocess.run(["git", "clone", str(bare), str(work)], capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(work), "commit", "--allow-empty", "-m", "remote commit"],
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["git", "-C", str(work), "push"], capture_output=True)
|
||||
|
||||
# After fetch the clone should appear behind
|
||||
assert _detect_behind_remote(str(clone)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _patch_task_status_in_file (T03 writeback)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPatchTaskStatusInFile:
|
||||
"""_patch_task_status_in_file must only change the status field in the
|
||||
matching task block and leave everything else untouched."""
|
||||
|
||||
def _make_workplan(self, tmp_path, content: str) -> Path:
|
||||
f = tmp_path / "workplan.md"
|
||||
f.write_text(content, encoding="utf-8")
|
||||
return f
|
||||
|
||||
def test_patches_matching_task_block(self, tmp_path):
|
||||
content = (
|
||||
"---\nid: WP-001\n---\n"
|
||||
"## My Task\n\n"
|
||||
"```task\n"
|
||||
"id: T01\n"
|
||||
"status: todo\n"
|
||||
"priority: high\n"
|
||||
"```\n"
|
||||
)
|
||||
f = self._make_workplan(tmp_path, content)
|
||||
result = _patch_task_status_in_file(f, "T01", "done")
|
||||
assert result is True
|
||||
patched = f.read_text()
|
||||
assert "status: done" in patched
|
||||
assert "status: todo" not in patched
|
||||
assert "priority: high" in patched # other fields untouched
|
||||
|
||||
def test_does_not_patch_non_matching_block(self, tmp_path):
|
||||
content = (
|
||||
"---\nid: WP-001\n---\n"
|
||||
"```task\n"
|
||||
"id: T02\n"
|
||||
"status: todo\n"
|
||||
"```\n"
|
||||
)
|
||||
f = self._make_workplan(tmp_path, content)
|
||||
result = _patch_task_status_in_file(f, "T01", "done")
|
||||
assert result is False
|
||||
assert "status: todo" in f.read_text()
|
||||
|
||||
def test_patches_correct_block_among_multiple(self, tmp_path):
|
||||
content = (
|
||||
"---\nid: WP-001\n---\n"
|
||||
"```task\nid: T01\nstatus: todo\n```\n"
|
||||
"```task\nid: T02\nstatus: in_progress\n```\n"
|
||||
)
|
||||
f = self._make_workplan(tmp_path, content)
|
||||
_patch_task_status_in_file(f, "T02", "done")
|
||||
patched = f.read_text()
|
||||
assert "id: T01\nstatus: todo" in patched
|
||||
assert "id: T02\nstatus: done" in patched
|
||||
|
||||
def test_idempotent_when_already_correct(self, tmp_path):
|
||||
content = "---\nid: WP\n---\n```task\nid: T01\nstatus: done\n```\n"
|
||||
f = self._make_workplan(tmp_path, content)
|
||||
result = _patch_task_status_in_file(f, "T01", "done")
|
||||
# status matches, re.sub produces same text → no write → False
|
||||
assert result is False
|
||||
|
||||
Reference in New Issue
Block a user