diff --git a/rein_aharness/fi_research_brief.py b/rein_aharness/fi_research_brief.py index 619d240..4ead5af 100644 --- a/rein_aharness/fi_research_brief.py +++ b/rein_aharness/fi_research_brief.py @@ -351,15 +351,48 @@ def _git(repo: Path, *args: str, timeout: int = 60) -> str: return result.stdout.strip() +_PUBLISH_PREFIX = "briefs/" + + def _publish_origin(repo: Path) -> str: - """Push HEAD to origin. FI-owned grant: origin is the durable brief store.""" + """Push HEAD to origin. FI-owned grant: origin is the durable brief store. + + FI decision 2026-09-22 (docs/decisions/2026-09-22-brief-origin-publication.md): + fast-forward only. If origin moved, rebase the unpublished brief commits + onto it once and retry. Never merge, never force. + """ branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD") if not branch or branch == "HEAD": branch = "main" - _git(repo, "push", "-u", "origin", f"HEAD:{branch}", timeout=120) + try: + _git(repo, "push", "-u", "origin", f"HEAD:{branch}", timeout=120) + except FiResearchBriefError: + _rebase_onto_origin(repo, branch) + _git(repo, "push", "-u", "origin", f"HEAD:{branch}", timeout=120) return _git(repo, "rev-parse", "HEAD") +def _rebase_onto_origin(repo: Path, branch: str) -> None: + """Replay local brief-only commits onto origin/, or fail closed.""" + _git(repo, "fetch", "origin", branch, timeout=120) + upstream = f"origin/{branch}" + changed = _git(repo, "diff", "--name-only", f"{upstream}...HEAD").splitlines() + outside = [p for p in changed if not p.startswith(_PUBLISH_PREFIX)] + if outside: + raise FiResearchBriefError( + f"unpublished commits touch paths outside {_PUBLISH_PREFIX}: {outside[:5]}" + ) + try: + _git(repo, "rebase", upstream, timeout=120) + except FiResearchBriefError: + subprocess.run( + ["git", "-C", str(repo), "rebase", "--abort"], + capture_output=True, + timeout=60, + ) + raise + + def run_fi_research_brief( target_repo: Path, *, diff --git a/tests/test_fi_research_brief.py b/tests/test_fi_research_brief.py index bba1a53..878a4c9 100644 --- a/tests/test_fi_research_brief.py +++ b/tests/test_fi_research_brief.py @@ -104,3 +104,67 @@ def test_fi_brief_push_failure_is_not_a_completed_day( assert posted assert posted[-1]["event_type"] == "executor_run" assert posted[-1]["detail"]["pushed"] is False + + +def _advance_origin(tmp_path: Path, name: str, rel: str) -> None: + """Commit to origin from a second clone so the executor's push is non-fast-forward.""" + other = tmp_path / name + subprocess.run(["git", "clone", "-q", str(tmp_path / "origin.git"), str(other)], check=True) + (other / rel).parent.mkdir(parents=True, exist_ok=True) + (other / rel).write_text("moved\n", encoding="utf-8") + _git(other, "add", rel) + _git(other, "-c", "user.email=o@example.invalid", "-c", "user.name=o", "commit", "-qm", "move origin") + _git(other, "push", "-q", "origin", "HEAD:main") + + +def test_fi_brief_rebases_once_when_origin_moved(tmp_path: Path) -> None: + repo = _repo(tmp_path) + _with_origin(tmp_path, repo) + _advance_origin(tmp_path, "other", "WORK-RECORDS.md") + result = fi_research_brief.run_fi_research_brief( + repo, day=date(2026, 9, 22), report_to_hub=False, complete_fn=_complete + ) + assert result.ok is True + assert result.pushed is True + remote_head = subprocess.run( + ["git", "-C", str(tmp_path / "origin.git"), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + assert remote_head == result.origin_sha + parents = subprocess.run( + ["git", "-C", str(repo), "rev-list", "--parents", "-1", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.split() + assert len(parents) == 2 # linear history: rebased, not merged + + +def test_fi_brief_refuses_rebase_of_non_brief_commits(tmp_path: Path) -> None: + repo = _repo(tmp_path) + _with_origin(tmp_path, repo) + (repo / "notes.md").write_text("local only\n", encoding="utf-8") + _git(repo, "add", "notes.md") + _git(repo, "-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "-qm", "stray") + _advance_origin(tmp_path, "other", "WORK-RECORDS.md") + result = fi_research_brief.run_fi_research_brief( + repo, day=date(2026, 9, 22), report_to_hub=False, complete_fn=_complete + ) + assert result.ok is False + assert result.pushed is False + assert "outside briefs/" in result.reason + + +def test_fi_brief_rebase_conflict_fails_closed(tmp_path: Path) -> None: + repo = _repo(tmp_path) + _with_origin(tmp_path, repo) + _advance_origin(tmp_path, "other", "briefs/2026/09/2026-09-22.md") + result = fi_research_brief.run_fi_research_brief( + repo, day=date(2026, 9, 22), report_to_hub=False, complete_fn=_complete + ) + assert result.ok is False + assert result.pushed is False + status = subprocess.run( + ["git", "-C", str(repo), "status", "--porcelain=v2", "--branch"], + check=True, capture_output=True, text=True, + ).stdout + assert "rebase" not in status.lower() + assert not (repo / ".git" / "rebase-merge").exists()