diff --git a/rein_aharness/repository_artifact.py b/rein_aharness/repository_artifact.py index 8a5166f..f8f5950 100644 --- a/rein_aharness/repository_artifact.py +++ b/rein_aharness/repository_artifact.py @@ -119,10 +119,14 @@ class RepositoryArtifactTransfer: raise RepositoryArtifactError( "artifact transfer requires the active repository transaction" ) - if grant.min_commits != 1 or grant.max_commits != 1 or grant.publish: + if grant.min_commits != 1 or grant.max_commits != 1: raise RepositoryArtifactError( "artifact transfer v1 requires exactly one local commit" ) + # A publish grant (v2) only marks the imported commit for Activity + # Core's publisher; this transfer never pushes. + if grant.publish and grant.publish_scope is None: + raise RepositoryArtifactError("publish grant has no publish_scope") self.transaction = transaction self.grant = grant self.cancel = resolve_cancel(cancel) @@ -284,7 +288,16 @@ class RepositoryArtifactTransfer: _git( staging, "merge", "--ff-only", "--no-edit", "--no-stat", self._head ) - inspection.validate_acceptance(self.grant.acceptance_policy()) + inspected = inspection.validate_acceptance( + self.grant.acceptance_policy() + ) + scope = self.grant.publish_scope + if scope is not None and not all( + scope.covers_path(path) for path in inspected.changed_paths + ): + raise RepositoryArtifactError( + "artifact changes paths outside the publish scope" + ) self._check() _git( baseline.repo_root, @@ -316,5 +329,7 @@ class RepositoryArtifactTransfer: "head": accepted.head, "imported": True, } + if self.grant.publish: + evidence["published"] = False self._bundle = None return evidence diff --git a/tests/test_repository_artifact.py b/tests/test_repository_artifact.py index 62eaba1..a35e9b9 100644 --- a/tests/test_repository_artifact.py +++ b/tests/test_repository_artifact.py @@ -14,6 +14,7 @@ from rein_aharness.execution_cancel import ExecutionCancel, ExecutionCancelled from rein_aharness.repository_artifact import ( RepositoryArtifactError, RepositoryArtifactTransfer, + _git as _git_spy, ) from rein_aharness.repository_grant import RepositoryGrant from rein_aharness.repository_transaction import ( @@ -310,3 +311,45 @@ def test_ignored_sandbox_cache_is_not_imported(tmp_path): transfer.import_after_teardown(head) assert not (source / "cache").exists() assert tx.acceptance.changed_paths == ("result.txt",) + + +def _publish_grant(): + return RepositoryGrant.from_mapping( + { + "version": "2", + "allowed_paths": ["out/"], + "commit_count": {"min": 1, "max": 1}, + "publish": True, + "publish_scope": { + "remote": "origin", + "branch": "main", + "path_prefixes": ["out/pub/"], + "fast_forward_only": True, + }, + } + ) + + +def test_publish_grant_imports_locally_and_leaves_push_to_publisher(tmp_path): + source, sandbox, _ = fixture(tmp_path) + with RepositoryTransaction(source, state_dir=tmp_path / "state") as tx: + transfer = RepositoryArtifactTransfer(tx, _publish_grant()) + head = commit(sandbox, "out/pub/brief.md") + capture(transfer, sandbox, head) + with patch("rein_aharness.repository_artifact._git", wraps=_git_spy) as spy: + result = transfer.import_after_teardown(head) + assert not any("push" in call.args for call in spy.call_args_list) + assert result["imported"] and result["published"] is False + assert git(source, "rev-parse", "HEAD") == head + + +def test_publish_grant_refuses_paths_outside_publish_scope(tmp_path): + source, sandbox, _ = fixture(tmp_path) + with RepositoryTransaction(source, state_dir=tmp_path / "state") as tx: + baseline = tx.baseline.head + transfer = RepositoryArtifactTransfer(tx, _publish_grant()) + head = commit(sandbox, "out/private.md") + capture(transfer, sandbox, head) + with pytest.raises(RepositoryArtifactError, match="publish scope"): + transfer.import_after_teardown(head) + assert git(source, "rev-parse", "HEAD") == baseline