from __future__ import annotations import json import subprocess from pathlib import Path import pytest from opensquilla.tools.builtin.shell import exec_command from opensquilla.tools.mutation_receipts import ( fingerprint_file, record_semantic_mutation_receipt, ) from opensquilla.tools.source_diff_preservation import ( source_diff_preservation_decision, ) from opensquilla.tools.types import ToolContext, current_tool_context def _init_git_workspace(path: Path) -> None: subprocess.run(["git", "init"], cwd=path, check=True, stdout=subprocess.DEVNULL) subprocess.run( ["git", "config", "user.email", "test@example.com"], cwd=path, check=True, ) subprocess.run(["git", "config", "user.name", "Test User"], cwd=path, check=True) def _commit_file(workspace: Path, relative_path: str, text: str) -> Path: target = workspace / relative_path target.parent.mkdir(parents=True, exist_ok=True) target.write_text(text, encoding="utf-8") subprocess.run(["git", "add", relative_path], cwd=workspace, check=True) subprocess.run(["git", "commit", "-m", "initial"], cwd=workspace, check=True) return target def _record_changed_source_receipt(ctx: ToolContext, target: Path) -> None: before = fingerprint_file(target) target.write_text("print('after')\n", encoding="utf-8") after = fingerprint_file(target) record_semantic_mutation_receipt( tool_name="edit_source", path=target, operation="edit_source", before=before, after=after, partial=False, ctx=ctx, ) @pytest.fixture def protected_workspace(tmp_path: Path): workspace = tmp_path / "workspace" workspace.mkdir() _init_git_workspace(workspace) target = _commit_file(workspace, "src/app.py", "print('before')\n") events: list[dict] = [] ctx = ToolContext( workspace_dir=str(workspace), source_diff_preservation_mode="block", on_runtime_event=events.append, session_key="agent:main:test", ) token = current_tool_context.set(ctx) try: _record_changed_source_receipt(ctx, target) yield workspace, target, ctx, events finally: current_tool_context.reset(token) def test_decision_blocks_git_restore_for_protected_source_path(protected_workspace) -> None: workspace, _target, ctx, _events = protected_workspace decision = source_diff_preservation_decision( command="git restore src/app.py", workdir=workspace, ctx=ctx, ) assert decision is not None assert decision.should_block is True assert decision.payload["reason"] == "source_diff_revert_blocked" assert decision.payload["matched_operation"] == "git_restore" assert decision.payload["protected_source_paths"] == ["src/app.py"] def test_decision_blocks_worktree_wide_destructive_commands(protected_workspace) -> None: workspace, _target, ctx, _events = protected_workspace for command, operation in ( ("git checkout -- .", "git_checkout"), ("git checkout --ours src/app.py", "git_checkout"), ("cd /tmp && git checkout src/app.py && echo restored", "git_checkout"), ( 'cd /tmp && git checkout -- src/app.py 2>&1 || echo "restore failed"', "git_checkout", ), ( 'cd /tmp && git checkout src/app.py 2>&1 || echo "restore failed"', "git_checkout", ), ("git reset --hard", "git_reset_hard"), ): decision = source_diff_preservation_decision( command=command, workdir=workspace, ctx=ctx, ) assert decision is not None assert decision.should_block is True assert decision.payload["matched_operation"] == operation def test_decision_allows_unrelated_path_restore(protected_workspace) -> None: workspace, _target, ctx, _events = protected_workspace decision = source_diff_preservation_decision( command="git restore docs/readme.md", workdir=workspace, ctx=ctx, ) assert decision is None def test_decision_allows_read_only_git_commands(protected_workspace) -> None: workspace, _target, ctx, _events = protected_workspace assert ( source_diff_preservation_decision( command="git diff -- src/app.py", workdir=workspace, ctx=ctx, ) is None ) assert ( source_diff_preservation_decision( command="git status --short", workdir=workspace, ctx=ctx, ) is None ) def test_log_mode_marks_matching_source_candidate_lost(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() _init_git_workspace(workspace) target = _commit_file(workspace, "src/app.py", "print('before')\n") events: list[dict] = [] ctx = ToolContext( workspace_dir=str(workspace), source_diff_preservation_mode="log", on_runtime_event=events.append, ) _record_changed_source_receipt(ctx, target) assert len(ctx.source_diff_candidates) == 1 decision = source_diff_preservation_decision( command="git checkout -- src/app.py", workdir=workspace, ctx=ctx, ) assert decision is not None assert decision.should_block is False assert ctx.source_diff_candidates[0]["lost"] is True assert ctx.source_diff_candidates[0]["lost_command"] == "git checkout -- src/app.py" assert any( event.get("name") == "source_diff_candidate.marked_lost" for event in events ) def test_unrelated_restore_does_not_mark_source_candidate_lost(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() _init_git_workspace(workspace) target = _commit_file(workspace, "src/app.py", "print('before')\n") ctx = ToolContext( workspace_dir=str(workspace), source_diff_preservation_mode="log", ) _record_changed_source_receipt(ctx, target) decision = source_diff_preservation_decision( command="git checkout -- docs/readme.md", workdir=workspace, ctx=ctx, ) assert decision is None assert ctx.source_diff_candidates[0]["lost"] is False def test_decision_blocks_git_clean_for_protected_untracked_source(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() _init_git_workspace(workspace) target = workspace / "src" / "new_feature.py" target.parent.mkdir(parents=True) before = {"exists": False, "size": 0, "sha256": None} target.write_text("print('new')\n", encoding="utf-8") after = fingerprint_file(target) ctx = ToolContext(workspace_dir=str(workspace), source_diff_preservation_mode="block") record_semantic_mutation_receipt( tool_name="edit_source", path=target, operation="edit_source", before=before, after=after, partial=False, ctx=ctx, ) decision = source_diff_preservation_decision( command="git clean -fd", workdir=workspace, ctx=ctx, ) assert decision is not None assert decision.should_block is True assert decision.payload["matched_operation"] == "git_clean" assert decision.payload["protected_source_paths"] == ["src/new_feature.py"] @pytest.mark.asyncio async def test_exec_command_blocks_source_diff_revert_and_preserves_file( protected_workspace, ) -> None: workspace, target, _ctx, events = protected_workspace result = await exec_command("git restore src/app.py", workdir=str(workspace)) payload = json.loads(result) assert payload["status"] == "blocked" assert payload["reason"] == "source_diff_revert_blocked" assert target.read_text(encoding="utf-8") == "print('after')\n" assert any( event.get("name") == "source_diff_revert_blocked" for event in events ) @pytest.mark.asyncio async def test_exec_command_log_mode_allows_revert_and_records_event(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() _init_git_workspace(workspace) target = _commit_file(workspace, "src/app.py", "print('before')\n") events: list[dict] = [] ctx = ToolContext( workspace_dir=str(workspace), source_diff_preservation_mode="log", on_runtime_event=events.append, session_key="agent:main:test", ) token = current_tool_context.set(ctx) try: _record_changed_source_receipt(ctx, target) result = await exec_command("git restore src/app.py", workdir=str(workspace)) finally: current_tool_context.reset(token) assert result.startswith("exit_code=0") assert target.read_text(encoding="utf-8") == "print('before')\n" assert any( event.get("name") == "source_diff_revert_observed" for event in events ) assert ctx.source_diff_candidates[0]["lost"] is True