|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import json |
| 6 | +import os |
| 7 | +import subprocess |
6 | 8 | from pathlib import Path |
7 | 9 | from unittest.mock import patch |
8 | 10 |
|
9 | 11 | import nbformat |
10 | 12 | import pytest |
11 | 13 | from click.testing import CliRunner |
12 | 14 |
|
| 15 | +from jupyter_jcli import pair_baseline |
13 | 16 | from jupyter_jcli.cli import main |
14 | 17 | from jupyter_jcli.drift import DriftResult |
15 | 18 |
|
@@ -77,6 +80,43 @@ def _make_pair(tmp_path: Path, py_src: list[str], ipynb_src: list[str]) -> tuple |
77 | 80 | return py, ipynb |
78 | 81 |
|
79 | 82 |
|
| 83 | +def _git(repo: Path, *args: str, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: |
| 84 | + return subprocess.run( |
| 85 | + ["git", *args], |
| 86 | + cwd=str(repo), |
| 87 | + check=check, |
| 88 | + capture_output=True, |
| 89 | + text=True, |
| 90 | + env=env, |
| 91 | + ) |
| 92 | + |
| 93 | + |
| 94 | +def _git_env(ts: int) -> dict[str, str]: |
| 95 | + env = os.environ.copy() |
| 96 | + stamp = f"@{ts} +0000" |
| 97 | + env["GIT_AUTHOR_DATE"] = stamp |
| 98 | + env["GIT_COMMITTER_DATE"] = stamp |
| 99 | + return env |
| 100 | + |
| 101 | + |
| 102 | +@pytest.fixture |
| 103 | +def git_repo(tmp_path: Path) -> Path: |
| 104 | + subprocess.run(["git", "init"], cwd=str(tmp_path), check=True, capture_output=True) |
| 105 | + subprocess.run( |
| 106 | + ["git", "config", "user.email", "test@test.com"], |
| 107 | + cwd=str(tmp_path), |
| 108 | + check=True, |
| 109 | + capture_output=True, |
| 110 | + ) |
| 111 | + subprocess.run( |
| 112 | + ["git", "config", "user.name", "Test User"], |
| 113 | + cwd=str(tmp_path), |
| 114 | + check=True, |
| 115 | + capture_output=True, |
| 116 | + ) |
| 117 | + return tmp_path |
| 118 | + |
| 119 | + |
80 | 120 | # --------------------------------------------------------------------------- |
81 | 121 | # pair-drift-guard-pre no longer handles NotebookEdit (moved to notebook-edit-guard) |
82 | 122 | # --------------------------------------------------------------------------- |
@@ -552,6 +592,92 @@ def test_ipynb_edit_in_post_is_silent(self, tmp_path): |
552 | 592 | assert _decision(out) is None # no output — Pre was the line of defense |
553 | 593 |
|
554 | 594 |
|
| 595 | +# --------------------------------------------------------------------------- |
| 596 | +# pair baseline integration — real git repo, no drift mock |
| 597 | +# --------------------------------------------------------------------------- |
| 598 | + |
| 599 | +class TestConsecutiveEdits: |
| 600 | + def test_post_uses_sticky_ref_to_avoid_false_conflict(self, git_repo: Path, monkeypatch: pytest.MonkeyPatch): |
| 601 | + py, ipynb = _make_pair(git_repo, ["x = 1"], ["x = 1"]) |
| 602 | + _git(git_repo, "add", "nb.py") |
| 603 | + _git(git_repo, "commit", "-m", "init", env=_git_env(100)) |
| 604 | + |
| 605 | + py.write_text(py.read_text(encoding="utf-8").replace("x = 1", "x = 10"), encoding="utf-8") |
| 606 | + monkeypatch.setenv("GIT_AUTHOR_DATE", "@150 +0000") |
| 607 | + monkeypatch.setenv("GIT_COMMITTER_DATE", "@150 +0000") |
| 608 | + code1, out1 = _invoke_post({"tool_name": "Edit", "tool_input": {"file_path": str(py)}}) |
| 609 | + |
| 610 | + assert code1 == 0 |
| 611 | + assert "Auto-synced" in _additional_context(out1) |
| 612 | + first_ref = _git( |
| 613 | + git_repo, |
| 614 | + "log", |
| 615 | + "-1", |
| 616 | + "--format=%ct", |
| 617 | + pair_baseline._ref_name("nb.py"), |
| 618 | + ).stdout.strip() |
| 619 | + nb1 = nbformat.read(str(ipynb), as_version=4) |
| 620 | + assert [cell.source for cell in nb1.cells if cell.source.strip()] == ["x = 10"] |
| 621 | + |
| 622 | + py.write_text(py.read_text(encoding="utf-8").replace("x = 10", "x = 20"), encoding="utf-8") |
| 623 | + monkeypatch.setenv("GIT_AUTHOR_DATE", "@160 +0000") |
| 624 | + monkeypatch.setenv("GIT_COMMITTER_DATE", "@160 +0000") |
| 625 | + code2, out2 = _invoke_post({"tool_name": "Edit", "tool_input": {"file_path": str(py)}}) |
| 626 | + |
| 627 | + assert code2 == 0 |
| 628 | + assert "Auto-synced" in _additional_context(out2) |
| 629 | + second_ref = _git( |
| 630 | + git_repo, |
| 631 | + "log", |
| 632 | + "-1", |
| 633 | + "--format=%ct", |
| 634 | + pair_baseline._ref_name("nb.py"), |
| 635 | + ).stdout.strip() |
| 636 | + nb2 = nbformat.read(str(ipynb), as_version=4) |
| 637 | + assert [cell.source for cell in nb2.cells if cell.source.strip()] == ["x = 20"] |
| 638 | + assert int(second_ref) >= int(first_ref) |
| 639 | + |
| 640 | + |
| 641 | +class TestPreToPostChain: |
| 642 | + def test_pre_merge_updates_ref_so_following_post_does_not_conflict( |
| 643 | + self, |
| 644 | + git_repo: Path, |
| 645 | + monkeypatch: pytest.MonkeyPatch, |
| 646 | + ): |
| 647 | + py, ipynb = _make_pair(git_repo, ["x = 1"], ["x = 1"]) |
| 648 | + _git(git_repo, "add", "nb.py") |
| 649 | + _git(git_repo, "commit", "-m", "init", env=_git_env(100)) |
| 650 | + |
| 651 | + py.write_text(py.read_text(encoding="utf-8").replace("x = 1", "x = 10"), encoding="utf-8") |
| 652 | + monkeypatch.setenv("GIT_AUTHOR_DATE", "@150 +0000") |
| 653 | + monkeypatch.setenv("GIT_COMMITTER_DATE", "@150 +0000") |
| 654 | + code0, out0 = _invoke_post({"tool_name": "Edit", "tool_input": {"file_path": str(py)}}) |
| 655 | + assert code0 == 0 |
| 656 | + assert "Auto-synced" in _additional_context(out0) |
| 657 | + |
| 658 | + nb = nbformat.read(str(ipynb), as_version=4) |
| 659 | + nb.cells[0].source = "x = 30" |
| 660 | + nbformat.write(nb, str(ipynb)) |
| 661 | + |
| 662 | + monkeypatch.setenv("GIT_AUTHOR_DATE", "@170 +0000") |
| 663 | + monkeypatch.setenv("GIT_COMMITTER_DATE", "@170 +0000") |
| 664 | + pre_code, pre_out = _invoke({"tool_name": "Edit", "tool_input": {"file_path": str(py)}}) |
| 665 | + assert pre_code == 0 |
| 666 | + assert _decision(pre_out) == "deny" |
| 667 | + assert "Re-read" in _reason(pre_out) or "Someone else edited" in _reason(pre_out) |
| 668 | + assert "x = 30" in py.read_text(encoding="utf-8") |
| 669 | + |
| 670 | + py.write_text(py.read_text(encoding="utf-8").replace("x = 30", "x = 40"), encoding="utf-8") |
| 671 | + monkeypatch.setenv("GIT_AUTHOR_DATE", "@180 +0000") |
| 672 | + monkeypatch.setenv("GIT_COMMITTER_DATE", "@180 +0000") |
| 673 | + post_code, post_out = _invoke_post({"tool_name": "Edit", "tool_input": {"file_path": str(py)}}) |
| 674 | + |
| 675 | + assert post_code == 0 |
| 676 | + assert "Auto-synced" in _additional_context(post_out) |
| 677 | + nb_after = nbformat.read(str(ipynb), as_version=4) |
| 678 | + assert [cell.source for cell in nb_after.cells if cell.source.strip()] == ["x = 40"] |
| 679 | + |
| 680 | + |
555 | 681 | # --------------------------------------------------------------------------- |
556 | 682 | # --debug smoke tests for pair-drift-guard-pre and pair-drift-guard-post |
557 | 683 | # --------------------------------------------------------------------------- |
|
0 commit comments