"""Repository input identities use disposable Git fixtures, never remote effects.""" from copy import deepcopy import os from pathlib import Path import subprocess import sys import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) from govoplan_devkit import inputs from govoplan_devkit.inputs import InputSnapshotter, validate_input_declaration from govoplan_devkit.workspace import Project, Repository, source_fingerprint def git(repo, *arguments): return subprocess.run( ["git", "-C", str(repo), *arguments], capture_output=True, check=True ).stdout @pytest.fixture def fixture(tmp_path, monkeypatch): repositories = [] for name in ("alpha", "beta"): path = tmp_path / name path.mkdir() git(path, "init", "-q") (path / "source.txt").write_text("initial\n") git(path, "add", "source.txt") git( path, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture", ) repositories.append(Repository(name, path, ("alias-" + name,))) project = Project( "Fixture", tuple(repositories), {"tools": {}, "profiles": {"quick": ["one"]}, "checks": []}, ) # Avoid incidental edits by other agents affecting these source-input tests. # The actual tool-source hashing implementation is checked separately. monkeypatch.setattr( InputSnapshotter, "_tooling_identity", lambda *_: "fixture-devkit-source" ) return tmp_path, project, InputSnapshotter(project, workspace_root=tmp_path) def stage(identity="one", repos=None, **extra): return { "id": identity, "argv": ["true"], **({"inputs": {"repos": repos}} if repos is not None else {}), **extra, } def fingerprint(snapshot, name="one"): return snapshot["stages"][name]["fingerprint"] def test_undeclared_inputs_remain_whole_workspace(fixture): root, _, engine = fixture result = engine.snapshot([stage()]) assert result["complete_workspace"] is True assert result["observed_scope"]["repos"] == ["alpha", "beta"] assert result["stages"]["one"]["scope"] == { "version": 1, "kind": "workspace", "declared": False, "repos": ["alpha", "beta"], } (root / "beta/source.txt").write_text("changed\n") assert fingerprint(engine.snapshot([stage()])) != fingerprint(result) def test_scoped_input_does_not_read_unrelated_repository_bytes(fixture, monkeypatch): root, _, engine = fixture original = inputs.os.open def guarded(path, *args, **kwargs): if Path(path).is_relative_to(root / "beta"): raise AssertionError("Unrelated repository input was opened") return original(path, *args, **kwargs) monkeypatch.setattr(inputs.os, "open", guarded) first = engine.snapshot([stage(repos=["alpha"])]) (root / "beta/source.txt").write_text("unrelated change") second = engine.snapshot([stage(repos=["alpha"])]) assert fingerprint(first) == fingerprint(second) assert second["scan_stats"]["repositories"] == 1 assert second["scan_stats"]["git_calls"] == 2 assert second["complete_workspace"] is False def test_repository_union_scans_once_per_snapshot(fixture): _, _, engine = fixture result = engine.snapshot( [ stage("one", ["alpha"]), stage("two", ["alpha"]), stage("three", ["beta", "alpha"]), ] ) assert result["scan_stats"]["repositories"] == 2 assert result["scan_stats"]["git_calls"] == 4 assert result["stages"]["three"]["scope"]["repos"] == ["alpha", "beta"] def test_session_rechecks_metadata_but_reuses_stable_file_content(fixture): _, _, engine = fixture plan = [stage(repos=["alpha"])] before = engine.snapshot(plan) after = engine.snapshot(plan) assert fingerprint(before) == fingerprint(after) assert before["scan_stats"]["bytes"] == len("initial\n") assert after["scan_stats"]["bytes"] == 0 assert after["scan_stats"]["cache_hits"] == 1 assert after["scan_stats"]["git_calls"] == 2 def test_changed_bytes_with_restored_mtime_are_not_reused(fixture): root, _, engine = fixture plan = [stage(repos=["alpha"])] before = engine.snapshot(plan) path = root / "alpha/source.txt" metadata = path.stat() path.write_text("changed\n") os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) after = engine.snapshot(plan) assert fingerprint(after) != fingerprint(before) assert after["scan_stats"]["bytes"] == len("changed\n") def test_inode_replacement_with_same_size_and_mtime_is_not_reused(fixture): root, _, engine = fixture plan = [stage(repos=["alpha"])] before = engine.snapshot(plan) path = root / "alpha/source.txt" metadata = path.stat() replacement = root / "replacement.txt" replacement.write_text("changed\n") os.utime(replacement, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) replacement.replace(path) after = engine.snapshot(plan) assert fingerprint(before) != fingerprint(after) assert after["scan_stats"]["cache_hits"] == 0 @pytest.mark.parametrize("flag", ["assume-unchanged", "skip-worktree"]) def test_hidden_index_flags_do_not_hide_worktree_changes(fixture, flag): root, _, engine = fixture git(root / "alpha", "update-index", "--" + flag, "source.txt") before = engine.source_snapshot(["alpha"]) (root / "alpha/source.txt").write_text("hidden change\n") after = engine.source_snapshot(["alpha"]) assert before["observed_source_fingerprint"] != after["observed_source_fingerprint"] def test_index_head_and_new_deleted_files_are_bound(fixture): root, _, engine = fixture repo = root / "alpha" identities = [] def record(): identities.append( engine.source_snapshot(["alpha"])["observed_source_fingerprint"] ) record() (repo / "source.txt").write_text("changed\n") record() git(repo, "add", "source.txt") record() git( repo, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "change", ) record() (repo / "new.txt").write_text("new") record() (repo / "source.txt").unlink() record() assert len(set(identities)) == len(identities) def test_missing_registered_repository_becoming_present_invalidates(fixture): root, project, _ = fixture extra = Repository("missing", root / "missing") extended = Project("Fixture", (*project.repositories, extra), project.config) engine = InputSnapshotter(extended, workspace_root=root) before = engine.source_snapshot(["missing"]) extra.path.mkdir() git(extra.path, "init", "-q") after = engine.source_snapshot(["missing"]) assert before["observed_source_fingerprint"] != after["observed_source_fingerprint"] def test_unrelated_plan_config_edits_do_not_invalidate_scoped_stage(fixture): root, project, engine = fixture plan = [stage(repos=["alpha"])] before = engine.snapshot(plan) updated = deepcopy(project.config) updated["profiles"]["full"] = ["unrelated"] updated["checks"].append({"id": "unrelated", "argv": ["false"]}) other = InputSnapshotter( Project("Changed label", project.repositories, updated), workspace_root=root ) assert fingerprint(other.snapshot(plan)) == fingerprint(before) def test_scope_command_tools_and_tooling_change_invalidate(fixture): root, project, engine = fixture first = engine.snapshot([stage(repos=["alpha"])]) assert fingerprint(engine.snapshot([stage(repos=["beta"])])) != fingerprint(first) assert fingerprint( engine.snapshot([stage(repos=["alpha"], argv=["false"])]) ) != fingerprint(first) assert fingerprint( engine.snapshot([stage(repos=["alpha"])], tooling_fingerprint="new-env") ) != fingerprint(first) changed = deepcopy(project.config) changed["tools"] = {"node": "/another/node"} other = InputSnapshotter( Project("Fixture", project.repositories, changed), workspace_root=root ) assert fingerprint(other.snapshot([stage(repos=["alpha"])])) != fingerprint(first) def test_source_only_attestation_is_independent_of_plan_and_tooling(fixture): _, _, engine = fixture one = engine.snapshot([stage(repos=["alpha"])], tooling_fingerprint="env-one") two = engine.snapshot( [stage("different", ["alpha"], argv=["false"])], tooling_fingerprint="env-two" ) source = engine.source_snapshot(["alpha"]) assert ( one["observed_source_fingerprint"] == two["observed_source_fingerprint"] == source["observed_source_fingerprint"] ) assert ( one["stages"]["one"]["source_fingerprint"] == source["observed_source_fingerprint"] ) assert source["fingerprint_version"] == inputs.FINGERPRINT_VERSION @pytest.mark.parametrize( "value", [ None, {}, {"paths": ["src/**"]}, {"repos": []}, {"repos": "alpha"}, {"repos": ["unknown"]}, {"repos": ["alias-alpha"]}, {"repos": ["alpha", "alpha"]}, {"repos": [""]}, {"repos": ["alpha"], "extra": True}, ], ) def test_synthetic_stage_scopes_fail_closed(fixture, value): _, _, engine = fixture with pytest.raises(ValueError): engine.snapshot([stage(inputs=value)]) def test_duplicate_stage_ids_and_escaped_repository_paths_fail(fixture): root, project, engine = fixture with pytest.raises(ValueError, match="Duplicate"): engine.snapshot([stage(), stage()]) bad = Project("Bad", (Repository("outside", root.parent),), project.config) with pytest.raises(ValueError, match="escapes"): InputSnapshotter(bad, workspace_root=root) def test_declaration_validation_is_pure_before_dry_run(monkeypatch): monkeypatch.setattr( inputs, "git_bytes", lambda *_a, **_k: pytest.fail("Planning validation ran Git"), ) assert validate_input_declaration( {"repos": ["beta", "alpha"]}, {"alpha", "beta"} ) == {"repos": ["alpha", "beta"]} with pytest.raises(ValueError): validate_input_declaration({"repos": ["unknown"]}, {"alpha", "beta"}) def test_runtime_records_are_not_hashed_as_execution_plans(fixture): _, _, engine = fixture with pytest.raises(ValueError, match="freshly planned"): engine.snapshot([stage(repos=["alpha"], status="passed")]) def test_membership_count_is_bounded_before_entry_hashing(fixture, monkeypatch): root, _, engine = fixture (root / "alpha/new.txt").write_text("extra") monkeypatch.setattr(inputs, "MAX_REPOSITORY_ENTRIES", 1) with pytest.raises(ValueError, match="entry count"): engine.source_snapshot(["alpha"]) def test_symlink_to_ignored_file_inside_repository_binds_target(fixture): root, _, engine = fixture repo = root / "alpha" (repo / ".gitignore").write_text("ignored.txt\n") (repo / "ignored.txt").write_text("first") (repo / "linked.txt").symlink_to("ignored.txt") before = engine.source_snapshot(["alpha"]) (repo / "ignored.txt").write_text("other") assert ( engine.source_snapshot(["alpha"])["observed_source_fingerprint"] != before["observed_source_fingerprint"] ) def test_dangling_symlink_target_creation_is_observed(fixture): root, _, engine = fixture repo = root / "alpha" (repo / "linked.txt").symlink_to("future.txt") before = engine.source_snapshot(["alpha"]) (repo / "future.txt").write_text("created") assert ( engine.source_snapshot(["alpha"])["observed_source_fingerprint"] != before["observed_source_fingerprint"] ) def test_cross_repository_and_directory_symlinks_are_not_silently_reused(fixture): root, _, engine = fixture link = root / "alpha/linked.txt" link.symlink_to(root / "beta/source.txt") with pytest.raises(ValueError, match="escapes"): engine.source_snapshot(["alpha"]) link.unlink() link.symlink_to(".") with pytest.raises(ValueError, match="regular file"): engine.source_snapshot(["alpha"]) def test_file_change_during_hash_is_rejected(fixture, monkeypatch): root, _, engine = fixture path = root / "alpha/source.txt" original = inputs.os.fstat changed = False def mutate(descriptor): nonlocal changed metadata = original(descriptor) if not changed: changed = True path.write_text("changed during read") return metadata monkeypatch.setattr(inputs.os, "fstat", mutate) with pytest.raises(ValueError, match="changed"): engine._file_hash(path, inputs._stats()) def test_legacy_whole_project_api_remains_independent(fixture): _, project, engine = fixture legacy = source_fingerprint(project) engine.snapshot([stage(repos=["alpha"])]) assert source_fingerprint(project) == legacy def test_real_tooling_inventory_is_bound_and_memoized(tmp_path): repo = Repository("example", tmp_path) project = Project("Fixture", (repo,), {"tools": {}}) engine = InputSnapshotter(project, workspace_root=tmp_path) before_stats, after_stats = inputs._stats(), inputs._stats() before = engine._tooling_identity(before_stats) after = engine._tooling_identity(after_stats) assert before == after assert before_stats["tooling_files"] > 15 and before_stats["tooling_bytes"] > 0 assert after_stats["tooling_files"] == after_stats["tooling_cache_hits"] assert after_stats["tooling_bytes"] == 0