diff --git a/scripts/evolve/daily_worker.py b/scripts/evolve/daily_worker.py index 3e356035..606afc47 100644 --- a/scripts/evolve/daily_worker.py +++ b/scripts/evolve/daily_worker.py @@ -49,6 +49,67 @@ def _remote_branch_exists(remote: str, branch: str, cwd: Path) -> bool: return bool(output) +def _configure_bot_identity(clone: Path) -> None: + _run( + [ + "git", + "config", + "user.name", + os.environ.get("WIKI_BOT_NAME", "rocmkernelwiki-evolution[bot]"), + ], + cwd=clone, + ) + _run( + [ + "git", + "config", + "user.email", + os.environ.get( + "WIKI_BOT_EMAIL", + "rocmkernelwiki-evolution[bot]@users.noreply.github.com", + ), + ], + cwd=clone, + ) + + +def _sync_with_base( + clone: Path, + *, + base: str, + branch: str, + source_branch: str, +) -> None: + if source_branch != branch: + _run(["git", "switch", "-c", branch], cwd=clone) + return + # A rolling branch owns the discovery watermarks, but executable controller + # code and tests must always come from the latest protected base branch. + _run( + [ + "git", + "fetch", + "origin", + f"+{base}:refs/remotes/origin/{base}", + ], + cwd=clone, + ) + try: + _run(["git", "rebase", f"origin/{base}"], cwd=clone) + except RuntimeError: + # The clone is disposable, but abort explicitly so diagnostics see a + # clean branch and no caller can accidentally continue in a conflicted + # rebase state. + subprocess.run( + ["git", "rebase", "--abort"], + cwd=clone, + capture_output=True, + text=True, + check=False, + ) + raise + + def run_daily(args: argparse.Namespace) -> dict[str, str]: run_date = args.run_date or date.today().isoformat() branch = args.branch or rolling_branch(run_date) @@ -74,28 +135,12 @@ def run_daily(args: argparse.Namespace) -> dict[str, str]: ], cwd=Path(directory), ) - if source_branch != branch: - _run(["git", "switch", "-c", branch], cwd=clone) - _run( - [ - "git", - "config", - "user.name", - os.environ.get("WIKI_BOT_NAME", "rocmkernelwiki-evolution[bot]"), - ], - cwd=clone, - ) - _run( - [ - "git", - "config", - "user.email", - os.environ.get( - "WIKI_BOT_EMAIL", - "rocmkernelwiki-evolution[bot]@users.noreply.github.com", - ), - ], - cwd=clone, + _configure_bot_identity(clone) + _sync_with_base( + clone, + base=args.base, + branch=branch, + source_branch=source_branch, ) refresh = [ diff --git a/scripts/evolve/draft_pr.py b/scripts/evolve/draft_pr.py index e0ba1c87..0bbe380d 100644 --- a/scripts/evolve/draft_pr.py +++ b/scripts/evolve/draft_pr.py @@ -79,6 +79,13 @@ def _run( return result.stdout.strip() +def _push_branch(root: Path, branch: str) -> None: + _run( + ["git", "push", "--force-with-lease", "-u", "origin", branch], + root=root, + ) + + def publish_draft( *, root: Path, @@ -115,7 +122,7 @@ def publish_draft( ], root=root, ) - _run(["git", "push", "--force-with-lease", "-u", "origin", branch], root=root) + _push_branch(root, branch) existing = _run( [ "gh", diff --git a/tests/test_evolution.py b/tests/test_evolution.py index e0b2f7f5..d1b1285a 100644 --- a/tests/test_evolution.py +++ b/tests/test_evolution.py @@ -451,6 +451,177 @@ def test_final_summary_is_inside_the_enforced_budget(): ] +def test_rolling_worker_rebases_state_onto_latest_main(): + from evolve.daily_worker import _configure_bot_identity, _sync_with_base + from evolve.draft_pr import _push_branch + + def git(cwd, *arguments): + return subprocess.run( + ["git", *arguments], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + remote = root / "remote.git" + clone = root / "clone" + git(root, "init", "-q", "-b", "main", str(source)) + (source / "base.txt").write_text("base\n", encoding="utf-8") + git(source, "add", "base.txt") + git( + source, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-q", + "-m", + "base", + ) + git(root, "init", "--bare", "-q", str(remote)) + git(source, "remote", "add", "origin", str(remote)) + git(source, "push", "-q", "-u", "origin", "main") + + git(source, "switch", "-q", "-c", "bot/evolution") + (source / "state.txt").write_text("watermark\n", encoding="utf-8") + git(source, "add", "state.txt") + git( + source, + "-c", + "user.name=Bot", + "-c", + "user.email=bot@example.com", + "commit", + "-q", + "-m", + "state", + ) + git(source, "push", "-q", "-u", "origin", "bot/evolution") + + git(source, "switch", "-q", "main") + (source / "controller.txt").write_text("fixed controller\n", encoding="utf-8") + git(source, "add", "controller.txt") + git( + source, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-q", + "-m", + "controller fix", + ) + git(source, "push", "-q", "origin", "main") + + git(root, "clone", "-q", "--branch", "bot/evolution", str(remote), str(clone)) + _configure_bot_identity(clone) + _sync_with_base( + clone, + base="main", + branch="bot/evolution", + source_branch="bot/evolution", + ) + assert git(clone, "branch", "--show-current") == "bot/evolution" + assert git(clone, "show", "HEAD:state.txt") == "watermark" + assert git(clone, "show", "HEAD:controller.txt") == "fixed controller" + subprocess.run( + ["git", "merge-base", "--is-ancestor", "origin/main", "HEAD"], + cwd=clone, + check=True, + ) + _push_branch(clone, "bot/evolution") + assert git(clone, "rev-parse", "HEAD") == git( + remote, "rev-parse", "refs/heads/bot/evolution" + ) + + new_clone = root / "new-clone" + git(root, "clone", "-q", "--branch", "main", str(remote), str(new_clone)) + _configure_bot_identity(new_clone) + _sync_with_base( + new_clone, + base="main", + branch="bot/new-evolution", + source_branch="main", + ) + assert git(new_clone, "branch", "--show-current") == "bot/new-evolution" + assert git(new_clone, "rev-parse", "HEAD") == git( + new_clone, "rev-parse", "origin/main" + ) + + +def test_rolling_worker_aborts_conflicted_base_sync(): + from evolve.daily_worker import _configure_bot_identity, _sync_with_base + + def git(cwd, *arguments): + return subprocess.run( + ["git", *arguments], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + def commit(cwd, message): + git(cwd, "add", "conflict.txt") + git( + cwd, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-q", + "-m", + message, + ) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + remote = root / "remote.git" + clone = root / "clone" + git(root, "init", "-q", "-b", "main", str(source)) + (source / "conflict.txt").write_text("base\n", encoding="utf-8") + commit(source, "base") + git(root, "init", "--bare", "-q", str(remote)) + git(source, "remote", "add", "origin", str(remote)) + git(source, "push", "-q", "-u", "origin", "main") + + git(source, "switch", "-q", "-c", "bot/evolution") + (source / "conflict.txt").write_text("bot\n", encoding="utf-8") + commit(source, "bot state") + git(source, "push", "-q", "-u", "origin", "bot/evolution") + git(source, "switch", "-q", "main") + (source / "conflict.txt").write_text("main\n", encoding="utf-8") + commit(source, "main update") + git(source, "push", "-q", "origin", "main") + + git(root, "clone", "-q", "--branch", "bot/evolution", str(remote), str(clone)) + _configure_bot_identity(clone) + old_head = git(clone, "rev-parse", "HEAD") + try: + _sync_with_base( + clone, + base="main", + branch="bot/evolution", + source_branch="bot/evolution", + ) + except RuntimeError: + pass + else: + raise AssertionError("conflicted rebase unexpectedly succeeded") + assert git(clone, "rev-parse", "HEAD") == old_head + assert git(clone, "status", "--porcelain=v1") == "" + assert not (clone / ".git" / "rebase-merge").exists() + assert not (clone / ".git" / "rebase-apply").exists() + + def test_query_marks_upstream_pr_snippets_as_untrusted(): import query