diff --git a/python/test/setup_metadata_test.py b/python/test/setup_metadata_test.py index 589e6ab994..8d2619c573 100644 --- a/python/test/setup_metadata_test.py +++ b/python/test/setup_metadata_test.py @@ -25,6 +25,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import run as run_command # noqa: E402 PYPROJECT = REPO_ROOT / "python" / "pyproject.toml" @@ -297,7 +298,7 @@ def _git_tree(root: Path, files: dict[str, str]) -> Path: (root / name).write_text(text, encoding="utf-8") # Without the caller's GIT_* variables: under a commit hook GIT_INDEX_FILE # (and GIT_DIR) would turn these commands into writes to the caller's index. - environment = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + environment = git_env.drop() git = shutil.which("git") assert git is not None, "git is not on PATH" for command in (["init", "-q"], ["add", "-A"]): diff --git a/scripts/ci/check-research-digest-ids.py b/scripts/ci/check-research-digest-ids.py index 447077e09b..518e2252aa 100644 --- a/scripts/ci/check-research-digest-ids.py +++ b/scripts/ci/check-research-digest-ids.py @@ -26,6 +26,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import ( BinaryCommandResult, CommandOutputLimitExceeded, @@ -178,7 +179,7 @@ def load_baseline(path: Path) -> dict[str, Any]: def _git_environment() -> dict[str, str]: - env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env = git_env.drop() env.update({"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", "LC_ALL": "C"}) return env diff --git a/scripts/ci/check_agent_copyright_rule.py b/scripts/ci/check_agent_copyright_rule.py index a0a5926cb9..274b20a191 100644 --- a/scripts/ci/check_agent_copyright_rule.py +++ b/scripts/ci/check_agent_copyright_rule.py @@ -20,6 +20,7 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import CommandOutputLimitExceeded # noqa: E402 from scripts.lib.safe_subprocess import run as run_command # noqa: E402 @@ -43,7 +44,7 @@ def git_environment(root: Path) -> dict[str, str]: """ if root.resolve() == ROOT.resolve(): return dict(os.environ) - return {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + return git_env.drop() def tracked_rules(root: Path) -> list[str]: diff --git a/scripts/ci/check_flawfinder_new.py b/scripts/ci/check_flawfinder_new.py index 0ff5e4ac75..d4612d7b13 100644 --- a/scripts/ci/check_flawfinder_new.py +++ b/scripts/ci/check_flawfinder_new.py @@ -28,7 +28,6 @@ import collections import csv import io -import os import shutil import sys import tempfile @@ -36,6 +35,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command # Every suffix pre-commit's `identify` maps to the hook's types_or (c, c++, @@ -99,7 +99,7 @@ def findings(flawfinder: str, source: Path) -> list[tuple[int, str, str]]: def git_output(git: str, args: list[str], cwd: Path) -> tuple[int, str]: # Read-only git in `cwd`; the hook's GIT_DIR / GIT_INDEX_FILE are dropped so # `cwd` alone names the repository. - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env = git_env.drop() proc = run_command( [git, *args], allowed_executables=(git,), diff --git a/scripts/ci/check_licence_metadata.py b/scripts/ci/check_licence_metadata.py index 483824bfd9..a1a5f3e36c 100644 --- a/scripts/ci/check_licence_metadata.py +++ b/scripts/ci/check_licence_metadata.py @@ -50,6 +50,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import ( # noqa: E402 CommandTimedOut, CommandValidationError, @@ -143,7 +144,7 @@ def git_environment(root: Path) -> dict[str, str]: caller's repository, so they are dropped.""" if root.resolve() == REPO_ROOT.resolve(): return dict(os.environ) - return {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + return git_env.drop() def tracked_files(root: Path, paths: list[Path]) -> set[Path]: diff --git a/scripts/ci/check_render_on_master.py b/scripts/ci/check_render_on_master.py index 97676d4df7..09043ce501 100644 --- a/scripts/ci/check_render_on_master.py +++ b/scripts/ci/check_render_on_master.py @@ -29,7 +29,6 @@ from __future__ import annotations import argparse -import os import shutil import sys import tempfile @@ -40,6 +39,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import run as run_command # noqa: E402 # The files `make docs-render` writes; deliverables-check.sh keeps the same list @@ -69,7 +69,7 @@ class Verdict: def git_environment() -> dict[str, str]: """The caller's environment without GIT_* variables, which would point git at another repository (a hook's, a test's).""" - return {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + return git_env.drop() def git(root: Path, *args: str) -> str: diff --git a/scripts/ci/ffmpeg_patch_stack.py b/scripts/ci/ffmpeg_patch_stack.py index f415eac557..8e5af0cf49 100644 --- a/scripts/ci/ffmpeg_patch_stack.py +++ b/scripts/ci/ffmpeg_patch_stack.py @@ -23,6 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import CommandTimedOut from scripts.lib.safe_subprocess import run as run_command @@ -159,7 +160,7 @@ def series(repo: Path) -> list[str]: def isolated_git_environment() -> dict[str, str]: """Caller environment without GIT_* (hooks export them; -C cannot override).""" - environment = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + environment = git_env.drop() environment.update( LC_ALL="C", GIT_TERMINAL_PROMPT="0", diff --git a/scripts/ci/lint-configured.py b/scripts/ci/lint-configured.py index 9c0267e89f..e1177f8d3f 100644 --- a/scripts/ci/lint-configured.py +++ b/scripts/ci/lint-configured.py @@ -13,7 +13,6 @@ import concurrent.futures import hashlib import json -import os import re import shlex import shutil @@ -24,6 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".cxx", ".cu", ".hip", ".m", ".mm"} @@ -39,7 +39,7 @@ def tracked_sources(root: Path) -> set[Path]: allowed_executables=(git,), check=True, capture_output=True, - env={key: value for key, value in os.environ.items() if not key.startswith("GIT_")}, + env=git_env.drop(), timeout_seconds=60, ) return { diff --git a/scripts/ci/run_affected_suites.py b/scripts/ci/run_affected_suites.py index 3f5c9cee3b..db4a92f1cb 100644 --- a/scripts/ci/run_affected_suites.py +++ b/scripts/ci/run_affected_suites.py @@ -46,6 +46,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import suite_registry as registry_lib +from scripts.lib import git_env from scripts.lib.safe_subprocess import CommandTimedOut, TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -83,7 +84,7 @@ class SuiteResult: def _env_without_git() -> dict[str, str]: drop = ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME") - return {k: v for k, v in os.environ.items() if not k.startswith("GIT_") and k not in drop} + return {k: v for k, v in git_env.drop().items() if k not in drop} def _git(root: Path, *args: str) -> str: diff --git a/scripts/ci/scorecard_gate.py b/scripts/ci/scorecard_gate.py index 20f588bea4..d06d54a857 100644 --- a/scripts/ci/scorecard_gate.py +++ b/scripts/ci/scorecard_gate.py @@ -28,6 +28,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command VERSION = "v5.6.0" @@ -219,7 +220,7 @@ def assess(report: dict[str, object], scope: str, repository: str, sha: str) -> def git(root: Path, *args: str) -> bytes: # ADR-1239: neither -C nor temporary repositories isolate inherited GIT_*. - env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env = git_env.drop() env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) executable = shutil.which("git") if executable is None: diff --git a/scripts/ci/suite_registry.py b/scripts/ci/suite_registry.py index 5b0bf34b61..eb897cf249 100755 --- a/scripts/ci/suite_registry.py +++ b/scripts/ci/suite_registry.py @@ -48,6 +48,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import CommandTimedOut from scripts.lib.safe_subprocess import run as run_command @@ -223,7 +224,7 @@ def tracked_files(root: Path) -> list[str]: if git is None: raise RegistryError("git is not on PATH") # A hook parent's GIT_DIR / GIT_INDEX_FILE would point git at another repo. - env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env = git_env.drop() result = run_command( [git, "-C", str(root), "ls-files", "-z"], allowed_executables=(git,), diff --git a/scripts/ci/test_ffmpeg_patch_smoke_safety.py b/scripts/ci/test_ffmpeg_patch_smoke_safety.py index 24c6c60b5c..a3243ab389 100644 --- a/scripts/ci/test_ffmpeg_patch_smoke_safety.py +++ b/scripts/ci/test_ffmpeg_patch_smoke_safety.py @@ -14,6 +14,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import ffmpeg_shared_series_fixture as shared_series +from scripts.lib import git_env from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -33,9 +34,7 @@ class SmokeSafety(unittest.TestCase): def git(self, path: Path, *args: str) -> str: # Isolate setup and assertions before injecting fake caller variables # into the smoke script itself. -C alone cannot select the fixture. - environment = { - key: value for key, value in os.environ.items() if not key.startswith("GIT_") - } + environment = git_env.drop() environment.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) git = shutil.which("git") self.assertIsNotNone(git) @@ -153,7 +152,7 @@ def configure_caller_environment(self) -> None: self.before_head = self.git(self.caller, "rev-parse", "HEAD") self.before_index = (self.caller / ".git/index").read_bytes() self.checkout = self.root / "ffmpeg-checkout" - self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.env = git_env.drop() self.env.update( { "GIT_CONFIG_NOSYSTEM": "1", diff --git a/scripts/ci/test_ffmpeg_patch_stack.py b/scripts/ci/test_ffmpeg_patch_stack.py index e1883192f2..befb95b914 100644 --- a/scripts/ci/test_ffmpeg_patch_stack.py +++ b/scripts/ci/test_ffmpeg_patch_stack.py @@ -19,6 +19,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import ffmpeg_shared_series_fixture as shared_series +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command SPEC = importlib.util.spec_from_file_location( @@ -51,7 +52,7 @@ def test_shared_series_inputs_check_out_with_lf(self) -> None: [git, "-C", str(repo), "check-attr", "eol", "--", *paths], allowed_executables={git}, # The caller's GIT_DIR or GIT_WORK_TREE would point git at another repository. - env={key: value for key, value in os.environ.items() if not key.startswith("GIT_")}, + env=git_env.drop(), capture_output=True, text=True, check=True, @@ -105,9 +106,7 @@ class RealReplay(unittest.TestCase): def git(self, path: Path, *args: str) -> str: # Fixed Git executable and fixture-owned arguments; no shell. # Fixture setup needs isolation too: -C does not override hook GIT_*. - environment = { - key: value for key, value in os.environ.items() if not key.startswith("GIT_") - } + environment = git_env.drop() environment.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) git = shutil.which("git") self.assertIsNotNone(git) diff --git a/scripts/ci/test_git_fixture_isolation.py b/scripts/ci/test_git_fixture_isolation.py index 64f31142f9..ad26982993 100644 --- a/scripts/ci/test_git_fixture_isolation.py +++ b/scripts/ci/test_git_fixture_isolation.py @@ -43,6 +43,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import CommandResult from scripts.lib.safe_subprocess import run as run_command @@ -107,13 +108,36 @@ r"""|["']config["']\s*,\s*(?:["']--(?:local|global|worktree|system|add|replace-all)["']\s*,\s*)*""" r"""["'][\w.-]+\.[\w.-]+["']\s*,\s*\S""" ) -# What counts as dropping the caller's variables: the two shell helpers (or their loop), and a -# filter on the GIT_ prefix in Python, Node and Go. +# What counts as dropping the caller's variables: the two shell helpers (or their loop), the +# Python twin (scripts/lib/git_env.py), and a filter on the GIT_ prefix in Node and Go. An +# inline Python filter is NOT a marker any more: scripts/lib/git_env.py is the one +# implementation (issue #3324) and test_no_python_file_filters_the_git_prefix_inline refuses +# the copies. ISOLATION_MARKER = re.compile( r"""(?:clean|drop)-git-env\.sh|compgen -A variable GIT_""" - r"""|startswith\(\s*\(?\s*["']GIT_|startsWith\(\s*["']GIT_|HasPrefix\([^)\n]*"GIT_""" + r"""|startsWith\(\s*["']GIT_|HasPrefix\([^)\n]*"GIT_""" r"""|clean_git_environment\(|\bgit_environment\(""" + r"""|git_env\.(?:drop|hermetic)\(|from scripts\.lib import git_env""" + r"""|from scripts\.lib\.git_env import""" ) +# One implementation of the GIT_* scrub: a Python file outside these two may not spell the +# prefix filter inline. git_env.py is the implementation; this checker plants the literal +# forms as negative controls and cannot scan itself for them. +INLINE_GIT_FILTER = re.compile(r"""startswith\(\s*\(?\s*(?:["']\w+["']\s*,\s*)*["']GIT_""") +INLINE_FILTER_ALLOWED = frozenset( + { + "scripts/lib/git_env.py", + "scripts/ci/test_git_fixture_isolation.py", + } +) +# One file, one reason, one expiry: a tracked Python file that keeps an inline GIT_* filter. +INLINE_FILTER_EXCEPTION_EXPIRY = "2026-12-31" +INLINE_FILTER_EXCEPTIONS = { + "tools/rc1-tester/image/licensing.py": ( + "copied alone into the tester image as /tool/licensing.py; scripts.lib is not in " + "the container (published-rc-licence-companions.yml)" + ), +} # One file, one reason, one expiry: a file whose git runs isolated in a way the marker cannot # see, with the place that does it. The sentinel run covers every test script among them. ISOLATION_EXCEPTION_EXPIRY = "2026-12-31" @@ -251,7 +275,7 @@ def commit_starts_background_work(repository: Path) -> bool: def sentinel_environment(sentinel: Path) -> dict[str, str]: """The environment a git hook gives its children: GIT_DIR and GIT_INDEX_FILE.""" - environment = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + environment = git_env.drop() environment.update(GIT_DIR=str(sentinel / ".git"), GIT_INDEX_FILE=str(sentinel / ".git/index")) environment["GIT_CONFIG_NOSYSTEM"] = "1" # Auto-maintenance would race the snapshot with a transient objects/maintenance.lock. @@ -319,7 +343,7 @@ def snapshot(directory: Path) -> dict[str, bytes]: def clean_git_environment() -> dict[str, str]: """Return a deterministic environment with all caller Git state removed.""" - clean = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + clean = git_env.drop() clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, LC_ALL="C") return clean @@ -616,6 +640,61 @@ def test_the_isolated_form_of_each_planted_script_leaves_the_sentinel_alone(self script.write_text(body) self.assertEqual(sentinel_damage(str(script), root), []) + def test_no_python_file_filters_the_git_prefix_inline(self) -> None: + # One implementation of the GIT_* scrub (issue #3324): scripts/lib/git_env.py. + # An inline copy fixed in one file does not reach the others, and a drop-only + # fixture still reads the user's global git configuration. + today = datetime.date.today().isoformat() + offenders = [] + for name in tracked_files(): + if not name.endswith(".py") or name in INLINE_FILTER_ALLOWED: + continue + path = ROOT / name + if not path.is_file(): + continue + if INLINE_GIT_FILTER.search(path.read_text(encoding="utf-8", errors="replace")): + if name in INLINE_FILTER_EXCEPTIONS and today <= INLINE_FILTER_EXCEPTION_EXPIRY: + continue + offenders.append(name) + self.assertEqual( + offenders, + [], + "inline GIT_* filter; build the environment with scripts/lib/git_env.py " + "(drop()/hermetic()):\n" + "\n".join(offenders), + ) + + def test_inline_filter_exceptions_are_live_and_needed(self) -> None: + self.assertGreaterEqual( + INLINE_FILTER_EXCEPTION_EXPIRY, datetime.date.today().isoformat(), "exceptions expired" + ) + for name, reason in INLINE_FILTER_EXCEPTIONS.items(): + with self.subTest(name=name): + self.assertTrue(reason) + text = (ROOT / name).read_text(encoding="utf-8", errors="replace") + self.assertTrue(INLINE_GIT_FILTER.search(text), "no longer needed: remove it") + + def test_the_inline_filter_rule_refuses_the_planted_copies(self) -> None: + planted = { + "t.py": ( + 'env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}\n', + " self.env = {key: value for key, value in os.environ.items()\n" + ' if not key.startswith("GIT_")}\n', + 'clean = {k: v for k, v in os.environ.items() if not k.startswith(("GIT_", "GH_"))}\n', + ' for key in [key for key in os.environ if key.startswith("GIT_")]:\n', + ), + } + for name, texts in planted.items(): + for text in texts: + with self.subTest(name=name, text=text[:60]): + self.assertTrue(INLINE_GIT_FILTER.search(text)) + for allowed in ( + "from scripts.lib.git_env import hermetic\nenv = hermetic()\n", + "environment = git_env.drop()\n", + "from scripts.lib import git_env\nclean = git_env.drop() | extra\n", + ): + with self.subTest(allowed=allowed[:60]): + self.assertIsNone(INLINE_GIT_FILTER.search(allowed)) + def test_no_script_creates_or_configures_a_repository_unisolated(self) -> None: today = datetime.date.today().isoformat() offenders = [] @@ -672,11 +751,13 @@ def test_isolation_rule_refuses_planted_unisolated_fixtures(self) -> None: "t.sh": '. "$ROOT/scripts/lib/clean-git-env.sh"\ngit init -q\ngit config core.bare true\n', "u.sh": '. "$ROOT/scripts/lib/drop-git-env.sh"\ngit clone -q "$url" "$dir"\n', "t.py": ( - 'env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}\n' + "from scripts.lib import git_env\n" + "env = git_env.drop()\n" 'subprocess.run(["git", "init"], env=env)\n' ), "u.py": ( - 'env = {k: v for k, v in os.environ.items() if not k.startswith(("GIT_", "GH_"))}\n' + "from scripts.lib import git_env\n" + 'env = git_env.drop(prefixes=("GIT_", "GH_"))\n' 'self.git("config", "core.bare", "true")\n' ), } diff --git a/scripts/ci/tests/test_base_image_single_source.py b/scripts/ci/tests/test_base_image_single_source.py index d6a45ef769..d70b6a2028 100644 --- a/scripts/ci/tests/test_base_image_single_source.py +++ b/scripts/ci/tests/test_base_image_single_source.py @@ -6,7 +6,6 @@ from __future__ import annotations import importlib.util -import os import shutil import sys import tempfile @@ -16,6 +15,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -83,7 +83,7 @@ def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.repo = Path(self.temporary.name) - self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.env = git_env.drop() for name in ( "build-config.env", ".node-version", diff --git a/scripts/ci/tests/test_check_adr_status_drift.py b/scripts/ci/tests/test_check_adr_status_drift.py index 029f5b701c..b6a6fe7c57 100644 --- a/scripts/ci/tests/test_check_adr_status_drift.py +++ b/scripts/ci/tests/test_check_adr_status_drift.py @@ -6,7 +6,6 @@ import importlib.util import json -import os import shutil import subprocess import sys @@ -16,6 +15,10 @@ from pathlib import Path from types import ModuleType +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] GIT_TIMEOUT_S = 60 @@ -49,7 +52,7 @@ def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.repo = Path(self.tmp.name) - self.env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + self.env = git_env.drop() self.env.update( GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null", diff --git a/scripts/ci/tests/test_check_agent_copyright_rule.py b/scripts/ci/tests/test_check_agent_copyright_rule.py index c8d53f80c8..82c64dcf94 100644 --- a/scripts/ci/tests/test_check_agent_copyright_rule.py +++ b/scripts/ci/tests/test_check_agent_copyright_rule.py @@ -14,13 +14,15 @@ from pathlib import Path from unittest import mock -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import check_agent_copyright_rule as rule +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env + def fixture_git_env() -> dict[str, str]: """Return a hermetic environment for disposable fixture repositories.""" - environment = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + environment = git_env.drop() environment.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) return environment diff --git a/scripts/ci/tests/test_check_bot_squash_body.py b/scripts/ci/tests/test_check_bot_squash_body.py index 405fb2a6c9..1412745ee1 100644 --- a/scripts/ci/tests/test_check_bot_squash_body.py +++ b/scripts/ci/tests/test_check_bot_squash_body.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: EUPL-1.2 """Planted-case tests for check-bot-squash-body.py. + Proves the defence catches the dangerous pattern AND passes safe ones: - REFUSE: bot PR, auto-merge armed, null body, non-bot commit present @@ -20,6 +21,9 @@ import tempfile from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env + SCRIPT = Path(__file__).resolve().parent.parent / "check-bot-squash-body.py" GIT = "git" GIT_TIMEOUT_S = 60 @@ -35,7 +39,7 @@ def _fixture_env( author_name: str, author_email: str, committer_name: str, committer_email: str ) -> dict[str, str]: - return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} | { + return git_env.drop() | { "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, "GIT_AUTHOR_NAME": author_name, @@ -123,7 +127,7 @@ def run_check(repo: Path, base: str, head: str, **env_overrides: str) -> int: Verifies gate exit codes across simulated PR metadata and commit authors. """ - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} | { + env = git_env.drop() | { "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, "PR_AUTHOR": "renovate[bot]", diff --git a/scripts/ci/tests/test_check_dco.py b/scripts/ci/tests/test_check_dco.py index 19c1e92f68..7b055c4251 100644 --- a/scripts/ci/tests/test_check_dco.py +++ b/scripts/ci/tests/test_check_dco.py @@ -3,6 +3,8 @@ # SPDX-License-Identifier: EUPL-1.2 """Fixture coverage for the DCO sign-off gate (ADR-2462). + + Every case builds a disposable repository, plants commits and asserts what ``scripts/ci/check-dco.py`` reports. A check never seen failing is not a check: ``test_planted_unsigned_commit_fails`` is the planted defect, and the @@ -23,6 +25,10 @@ import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] GATE = ROOT / "scripts" / "ci" / "check-dco.py" GIT = shutil.which("git") or "/usr/bin/git" @@ -48,8 +54,8 @@ """ -def git_env(ident: tuple[str, str]) -> dict[str, str]: - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} +def fixture_git_env(ident: tuple[str, str]) -> dict[str, str]: + env = git_env.drop() env.update( GIT_AUTHOR_NAME=ident[0], GIT_AUTHOR_EMAIL=ident[1], @@ -92,7 +98,7 @@ def git(self, args: list[str], ident: tuple[str, str] = HUMAN) -> str: done = subprocess.run( # noqa: S603 -- fixed argv, no shell, fixture repo [GIT, *args], cwd=self.path, - env=git_env(ident), + env=fixture_git_env(ident), capture_output=True, text=True, check=True, @@ -402,7 +408,7 @@ def test_committer_only_match_fails(self) -> None: n = len(list(self.repo.path.glob("f*.txt"))) (self.repo.path / f"f{n}.txt").write_text(f"{n}\n") self.repo.git(["add", "-A"], BOT_IDENT) - env = git_env(BOT_IDENT) + env = fixture_git_env(BOT_IDENT) env.update( GIT_COMMITTER_NAME="GitHub", GIT_COMMITTER_EMAIL="noreply@github.com", diff --git a/scripts/ci/tests/test_check_flawfinder_new.py b/scripts/ci/tests/test_check_flawfinder_new.py index 8ea57f2c8e..d5ddf454dd 100644 --- a/scripts/ci/tests/test_check_flawfinder_new.py +++ b/scripts/ci/tests/test_check_flawfinder_new.py @@ -31,6 +31,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) from scripts.ci import check_flawfinder_new as check +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command BASE = """\ @@ -73,7 +74,7 @@ def git(self, *args: str) -> None: # Drop the caller's repository variables (a hook runs with GIT_DIR and # GIT_INDEX_FILE set) so the fixture never touches the caller's repository. env = { - **{k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, + **git_env.drop(), "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, "GIT_AUTHOR_NAME": "t", diff --git a/scripts/ci/tests/test_check_licence_metadata.py b/scripts/ci/tests/test_check_licence_metadata.py index 1598a0e802..f48779258a 100644 --- a/scripts/ci/tests/test_check_licence_metadata.py +++ b/scripts/ci/tests/test_check_licence_metadata.py @@ -18,6 +18,7 @@ import json import os import shutil +import sys import tarfile import tempfile import tomllib @@ -26,6 +27,10 @@ from pathlib import Path from unittest import mock +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + REPO_ROOT = Path(__file__).resolve().parents[3] GATE = REPO_ROOT / "scripts" / "ci" / "check_licence_metadata.py" GIT_TIMEOUT_S = 60 @@ -59,7 +64,7 @@ def _fixture_environment() -> dict[str, str]: """No caller Git state: a commit hook exports GIT_INDEX_FILE (and may export GIT_DIR), which would turn `git init` / `git add` in a fixture into writes to the caller's index and config.""" - clean = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + clean = git_env.drop() clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) return clean diff --git a/scripts/ci/tests/test_check_render_on_master.py b/scripts/ci/tests/test_check_render_on_master.py index c79ca7c767..045a6f11aa 100644 --- a/scripts/ci/tests/test_check_render_on_master.py +++ b/scripts/ci/tests/test_check_render_on_master.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: EUPL-1.2 """Fixture coverage for the master-push render check (ADR-3295). + Every case builds a disposable repository whose stub ``Makefile`` renders ``CHANGELOG.md`` from ``changelog.d/`` and checks it, commits with fixed committer times and runs ``scripts/ci/check_render_on_master.py`` with a fixed @@ -13,7 +14,6 @@ from __future__ import annotations -import os import re import shutil import subprocess @@ -22,6 +22,9 @@ import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] CHECK = ROOT / "scripts" / "ci" / "check_render_on_master.py" WORKFLOW = ROOT / ".github" / "workflows" / "lint-and-format.yml" @@ -65,7 +68,7 @@ def git(self, *args: str, date: str | None = None) -> str: Constructs fixture commits with fixed commit timestamps. """ - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env = git_env.drop() env.update(IDENT) if date is not None: env.update(GIT_AUTHOR_DATE=date, GIT_COMMITTER_DATE=date) @@ -93,7 +96,7 @@ def check(self, *extra: str) -> subprocess.CompletedProcess[str]: ADR-3295: runs the master render check at a simulated wall-clock time. """ - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env = git_env.drop() return subprocess.run( # noqa: S603 -- fixed argv, no shell, check under test [sys.executable, str(CHECK), "--root", str(self.repo), "--now", str(NOW), *extra], env=env, diff --git a/scripts/ci/tests/test_check_silent_revert.py b/scripts/ci/tests/test_check_silent_revert.py index b7c2120fd1..02ff04e2dc 100644 --- a/scripts/ci/tests/test_check_silent_revert.py +++ b/scripts/ci/tests/test_check_silent_revert.py @@ -3,6 +3,8 @@ # SPDX-License-Identifier: EUPL-1.2 """Fixture coverage for the silent-revert gate (ADR-1284). + + Every case builds a disposable repository, reproduces one merge shape, and asserts what ``scripts/ci/check-silent-revert.py`` reports for it. The suite is positive / negative / boundary per HISS-15: a reproduced silent revert must be @@ -29,6 +31,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -44,9 +47,9 @@ ADR_0759_REVERTED = "92ea978a4" -def git_env() -> dict[str, str]: +def fixture_git_env() -> dict[str, str]: """A git environment that cannot reach the developer's real configuration.""" - clean = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + clean = git_env.drop() clean.update( GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, @@ -66,7 +69,7 @@ def git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProce """ return subprocess.run( # noqa: S603 -- fixed argv, no shell, disposable fixture repo [GIT, "-C", str(repo), *args], - env=git_env(), + env=fixture_git_env(), text=True, capture_output=True, check=check, @@ -82,7 +85,7 @@ def run_gate(repo: Path, base: str, head: str, **env: str) -> TextCommandResult: return run_command( [sys.executable, str(GATE), "--repo", str(repo), "--base", base, "--head", head], allowed_executables=(sys.executable,), - env={**git_env(), **env}, + env={**fixture_git_env(), **env}, text=True, capture_output=True, check=False, @@ -520,7 +523,7 @@ def test_real_history_replay(self) -> None: """ probe = subprocess.run( # noqa: S603 -- fixed argv, no shell [GIT, "-C", str(ROOT), "cat-file", "-e", f"{ADR_0759_REVERTED}^{{commit}}"], - env=git_env(), + env=fixture_git_env(), capture_output=True, text=True, check=False, diff --git a/scripts/ci/tests/test_check_source_adr_citations.py b/scripts/ci/tests/test_check_source_adr_citations.py index 4ae3f303f4..46053a4217 100644 --- a/scripts/ci/tests/test_check_source_adr_citations.py +++ b/scripts/ci/tests/test_check_source_adr_citations.py @@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -31,7 +32,7 @@ def fixture_git_env() -> dict[str, str]: """Return a hermetic environment for disposable fixture repositories.""" - environment = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + environment = git_env.drop() environment.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) return environment @@ -262,9 +263,7 @@ def test_fixture_git_discards_inherited_repository_environment(self) -> None: self.registry() with tempfile.TemporaryDirectory() as caller_tmp: caller = Path(caller_tmp) - clean_env = { - key: value for key, value in os.environ.items() if not key.startswith("GIT_") - } + clean_env = git_env.drop() clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) subprocess.run( # noqa: S603 -- fixed Git binary and disposable caller fixture [GIT, "-c", "core.hooksPath=/dev/null", "init", "-q", str(caller)], diff --git a/scripts/ci/tests/test_check_tidy_coverage.py b/scripts/ci/tests/test_check_tidy_coverage.py index 5922a0b0a4..f9a8905dde 100644 --- a/scripts/ci/tests/test_check_tidy_coverage.py +++ b/scripts/ci/tests/test_check_tidy_coverage.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: EUPL-1.2 """scripts/ci/check-tidy-coverage.py fails on a unit no lane reads. + Each case builds a throwaway git repository: baselines that measure some units, an exception list, and tracked units. The planted defect is a new ``.c`` file outside every lane and outside the exception list; the check must refuse it. @@ -12,7 +13,6 @@ import datetime import json -import os import shutil import subprocess import sys @@ -21,6 +21,9 @@ import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] SCRIPT = ROOT / "scripts/ci/check-tidy-coverage.py" TODAY = "2026-10-05" @@ -28,7 +31,7 @@ # A commit hook runs with GIT_INDEX_FILE and GIT_DIR set: a throwaway repository must not inherit # them, or `git add -A` below rewrites the index of the repository being committed. GIT = shutil.which("git") or "git" -CLEAN_ENV = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} +CLEAN_ENV = git_env.drop() def entry( diff --git a/scripts/ci/tests/test_ci_impact.py b/scripts/ci/tests/test_ci_impact.py index 2e5af0381a..b220550b62 100644 --- a/scripts/ci/tests/test_ci_impact.py +++ b/scripts/ci/tests/test_ci_impact.py @@ -25,6 +25,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) from scripts.ci import gate_leg_result as glr +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command REPO_ROOT = Path(__file__).resolve().parents[3] @@ -461,7 +462,7 @@ class GitIntegration(unittest.TestCase): def _repo(self) -> tuple[str, GitCommand]: tmp = tempfile.mkdtemp() env = { - **{k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, + **git_env.drop(), "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", diff --git a/scripts/ci/tests/test_cuda_pin_single_source.py b/scripts/ci/tests/test_cuda_pin_single_source.py index c997756d14..c21561b0ab 100644 --- a/scripts/ci/tests/test_cuda_pin_single_source.py +++ b/scripts/ci/tests/test_cuda_pin_single_source.py @@ -24,7 +24,6 @@ import importlib.util import io import json -import os import re import shutil import subprocess @@ -38,6 +37,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.renovate_regex import to_python ROOT = Path(__file__).resolve().parents[3] @@ -389,7 +389,7 @@ def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.repo = Path(self.temporary.name) - self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.env = git_env.drop() for name in (GATE, "build-config.env", "dev/Containerfile"): target = self.repo / name target.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/ci/tests/test_deliverables_render_only.py b/scripts/ci/tests/test_deliverables_render_only.py index f4350ad543..78db1adfb7 100644 --- a/scripts/ci/tests/test_deliverables_render_only.py +++ b/scripts/ci/tests/test_deliverables_render_only.py @@ -24,6 +24,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -82,7 +83,7 @@ def git(self, *args: str) -> str: Prepares commits, tags, and branches in the fixture repository. """ - env = {**{k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, **IDENT} + env = {**git_env.drop(), **IDENT} done = subprocess.run( # noqa: S603 -- fixed argv, no shell, fixture repo [GIT, *args], cwd=self.repo, @@ -111,7 +112,7 @@ def gate(self, body: str = BODY) -> TextCommandResult: ADR-3295: runs the gate to verify render-only PR qualification. """ env = {k: v for k, v in os.environ.items() if k not in ("PR_BODY", "BASE_SHA", "HEAD_SHA")} - env = {k: v for k, v in env.items() if not k.startswith("GIT_")} + env = git_env.drop(env) env.update(PR_BODY=body, BASE_SHA=self.base, HEAD_SHA=self.git("rev-parse", "HEAD").strip()) return run_command( [BASH, str(GATE)], diff --git a/scripts/ci/tests/test_deliverables_small_pr.py b/scripts/ci/tests/test_deliverables_small_pr.py index d7fb902c5d..b6220af39b 100644 --- a/scripts/ci/tests/test_deliverables_small_pr.py +++ b/scripts/ci/tests/test_deliverables_small_pr.py @@ -23,6 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -76,7 +77,7 @@ def git(self, *args: str) -> str: Configures baseline and branch commits in the fixture repository. """ - env = {**{k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, **IDENT} + env = {**git_env.drop(), **IDENT} done = subprocess.run( # noqa: S603 -- fixed argv, no shell, fixture repo [GIT, *args], cwd=self.repo, diff --git a/scripts/ci/tests/test_envtest_single_source.py b/scripts/ci/tests/test_envtest_single_source.py index a2f346dc1e..c7f0440875 100644 --- a/scripts/ci/tests/test_envtest_single_source.py +++ b/scripts/ci/tests/test_envtest_single_source.py @@ -17,6 +17,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.renovate_regex import to_python from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -56,9 +57,8 @@ def setUp(self) -> None: fake.mkdir() self.env = { k: v - for k, v in os.environ.items() - if not k.startswith(("GIT_", "MAKE", "GNUMAKE")) - and k not in {"MFLAGS", "ENVTEST_K8S_VERSION", "GOBIN", "GOPATH"} + for k, v in git_env.drop(prefixes=("GIT_", "MAKE", "GNUMAKE")).items() + if k not in {"MFLAGS", "ENVTEST_K8S_VERSION", "GOBIN", "GOPATH"} } self.env.update( PATH=str(fake) + os.pathsep + os.defpath, diff --git a/scripts/ci/tests/test_ffmpeg_patches_surface_check.py b/scripts/ci/tests/test_ffmpeg_patches_surface_check.py index 4306e85a9c..fae857ea1c 100644 --- a/scripts/ci/tests/test_ffmpeg_patches_surface_check.py +++ b/scripts/ci/tests/test_ffmpeg_patches_surface_check.py @@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT)) +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import TextCommandResult # noqa: E402 from scripts.lib.safe_subprocess import run as run_command # noqa: E402 @@ -29,7 +30,7 @@ def _clean_env(**extra: str) -> dict[str, str]: """Return the caller environment without GIT_* and with a fixture-only identity.""" - env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env = git_env.drop() env.update( GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull, diff --git a/scripts/ci/tests/test_level_zero_single_source.py b/scripts/ci/tests/test_level_zero_single_source.py index 0ef407d190..897bf955df 100644 --- a/scripts/ci/tests/test_level_zero_single_source.py +++ b/scripts/ci/tests/test_level_zero_single_source.py @@ -19,6 +19,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.renovate_regex import to_python from scripts.lib.safe_subprocess import run as run_command @@ -52,7 +53,7 @@ def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.repo = Path(self.temporary.name) - self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.env = git_env.drop() self.env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) (self.repo / "dev").mkdir() diff --git a/scripts/ci/tests/test_lint_configured.py b/scripts/ci/tests/test_lint_configured.py index 45ef4395c3..ea51a94c30 100644 --- a/scripts/ci/tests/test_lint_configured.py +++ b/scripts/ci/tests/test_lint_configured.py @@ -25,6 +25,7 @@ sys.path.insert(0, str(ROOT)) +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import TextCommandResult # noqa: E402 from scripts.lib.safe_subprocess import run as run_command # noqa: E402 @@ -183,7 +184,7 @@ def setUp(self) -> None: self.calls = self.root / "calls" self.calls.mkdir() self.env = dict( - {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}, + git_env.drop(), PATH=f"{self.bin}{os.pathsep}{os.environ['PATH']}", CALLS=str(self.calls), GIT_CONFIG_NOSYSTEM="1", diff --git a/scripts/ci/tests/test_nv_codec_headers_single_source.py b/scripts/ci/tests/test_nv_codec_headers_single_source.py index 8e2ee5213c..71cc14b665 100644 --- a/scripts/ci/tests/test_nv_codec_headers_single_source.py +++ b/scripts/ci/tests/test_nv_codec_headers_single_source.py @@ -41,6 +41,7 @@ ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT)) +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import TextCommandResult # noqa: E402 from scripts.lib.safe_subprocess import run as run_command # noqa: E402 @@ -150,7 +151,7 @@ def setUp(self) -> None: self.tmp = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, self.tmp, True) # The caller's GIT_* (a hook's GIT_DIR above all) must not reach the fixture's git. - self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.env = git_env.drop() self.env.update( GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1", diff --git a/scripts/ci/tests/test_open_render_pr.py b/scripts/ci/tests/test_open_render_pr.py index 3b6cf46935..8f5236741a 100644 --- a/scripts/ci/tests/test_open_render_pr.py +++ b/scripts/ci/tests/test_open_render_pr.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: EUPL-1.2 """Tests for the scheduled render pull request opener (ADR-3373). + Verifies author and cross-repository filtering for open PRs, auto-merge arming on parent==base early exit, detection of untracked files in rendered paths, and required environment validation. @@ -11,13 +12,17 @@ from __future__ import annotations import json -import os import shutil import subprocess +import sys import tempfile import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] SCRIPT = ROOT / "scripts" / "ci" / "open-render-pr.sh" GIT = shutil.which("git") or "/usr/bin/git" @@ -132,7 +137,7 @@ def eval_jq(data, expr): def fixture_env() -> dict[str, str]: """Environment for fixture git calls, without the caller's GIT_* variables.""" - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env = git_env.drop() env.update( GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null", @@ -226,7 +231,7 @@ def run_script( remove_env: tuple[str, ...] = (), ) -> subprocess.CompletedProcess[str]: """Execute the open-render-pr script with test harness environment and stubs.""" - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env = git_env.drop() env.update( PATH=f"{self.bin_dir}:{env.get('PATH', '')}", STUB_STATE_DIR=str(self.state_dir), diff --git a/scripts/ci/tests/test_pr_gates_merge_base.py b/scripts/ci/tests/test_pr_gates_merge_base.py index c6e35fa58a..e575afa10f 100644 --- a/scripts/ci/tests/test_pr_gates_merge_base.py +++ b/scripts/ci/tests/test_pr_gates_merge_base.py @@ -15,13 +15,17 @@ from __future__ import annotations -import os import shutil import subprocess +import sys import tempfile import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] CI = ROOT / "scripts" / "ci" GIT = shutil.which("git") or "/usr/bin/git" @@ -30,7 +34,7 @@ SCRIPT_TIMEOUT_S = 60 # A git hook exports GIT_DIR and GIT_INDEX_FILE; with them the fixture's init, commits and # checkouts, and the gates under test, would act on the caller's repository. -CLEAN = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} +CLEAN = git_env.drop() IDENT = { "GIT_AUTHOR_NAME": "Ada", "GIT_AUTHOR_EMAIL": "ada@example.org", diff --git a/scripts/ci/tests/test_praetor_tidy_coverage.py b/scripts/ci/tests/test_praetor_tidy_coverage.py index bbf8b2be17..cc8b30f28a 100644 --- a/scripts/ci/tests/test_praetor_tidy_coverage.py +++ b/scripts/ci/tests/test_praetor_tidy_coverage.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: EUPL-1.2 """scripts/ci/praetor_tidy_coverage.py renders praetor's lane list and exceptions, and refuses a stale one. + Each case builds a throwaway git repository. The planted defects: a unit a lane measures that the lane file lacks, an exception the manifest block lacks, and a block edited by hand; ``--check`` must fail each, and ``--write`` must repair it. A HISS-11 entry of the repository list reaches the @@ -12,7 +13,6 @@ from __future__ import annotations import json -import os import shutil import subprocess import sys @@ -20,10 +20,14 @@ import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] SCRIPT = ROOT / "scripts/ci/praetor_tidy_coverage.py" GIT = shutil.which("git") or "git" -CLEAN_ENV = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} +CLEAN_ENV = git_env.drop() TOML = ( '[[exception]]\npath = "src/win32.c"\ncategory = "A"\nentered = 2026-11-02\n' 'reason = "Windows only"\nexpires = 2027-06-30\n' diff --git a/scripts/ci/tests/test_pre_push_mkdocs_strict.py b/scripts/ci/tests/test_pre_push_mkdocs_strict.py index 84bc221849..c334ede1c0 100644 --- a/scripts/ci/tests/test_pre_push_mkdocs_strict.py +++ b/scripts/ci/tests/test_pre_push_mkdocs_strict.py @@ -17,14 +17,18 @@ from __future__ import annotations -import os import re import shutil import subprocess +import sys import tempfile import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + REPO = Path(__file__).resolve().parents[3] HOOK = REPO / "scripts" / "git-hooks" / "pre-push-mkdocs-strict.sh" MKDOCS = shutil.which("mkdocs") @@ -44,7 +48,7 @@ def _run(cmd: list[str], cwd: Path, timeout: int) -> subprocess.CompletedProcess[str]: # No GIT_DIR / GIT_INDEX_FILE from a hook: they would point git at the caller's repository. - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env = git_env.drop() env.pop("SKIP", None) env.pop("PRE_COMMIT_REMOTE_NAME", None) return subprocess.run( # noqa: S603 -- fixed argv, no shell, disposable fixture repo diff --git a/scripts/ci/tests/test_renovate_file_patterns.py b/scripts/ci/tests/test_renovate_file_patterns.py index 2aa3dea595..0416efd989 100644 --- a/scripts/ci/tests/test_renovate_file_patterns.py +++ b/scripts/ci/tests/test_renovate_file_patterns.py @@ -12,7 +12,6 @@ from __future__ import annotations import json -import os import re import shutil import sys @@ -23,6 +22,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.renovate_regex import to_python from scripts.lib.safe_subprocess import run as run_command @@ -75,9 +75,7 @@ class RenovateFilePatterns(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.config = json.loads((ROOT / "renovate.json").read_text(encoding="utf-8")) - environment = { - key: value for key, value in os.environ.items() if not key.startswith("GIT_") - } + environment = git_env.drop() result = run_command( [GIT, "-C", str(ROOT), "ls-files"], allowed_executables=(GIT,), diff --git a/scripts/ci/tests/test_run_affected_suites.py b/scripts/ci/tests/test_run_affected_suites.py index 48107dcdb4..6a55914c10 100644 --- a/scripts/ci/tests/test_run_affected_suites.py +++ b/scripts/ci/tests/test_run_affected_suites.py @@ -13,7 +13,6 @@ import hashlib import json -import os import shutil import subprocess import sys @@ -23,9 +22,9 @@ ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT)) - from scripts.ci import run_affected_suites as runner # noqa: E402 from scripts.ci import suite_registry as registry_lib # noqa: E402 +from scripts.lib import git_env # noqa: E402 SCRIPT = ROOT / "scripts" / "ci" / "run_affected_suites.py" GIT = shutil.which("git") @@ -184,7 +183,7 @@ def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="affected-run-") self.addCleanup(self._tmp.cleanup) self.repo = Path(self._tmp.name) - self.env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + self.env = git_env.drop() self.git("init", "-q") def git(self, *args: str) -> None: diff --git a/scripts/ci/tests/test_rust_ci_workflow_contract.py b/scripts/ci/tests/test_rust_ci_workflow_contract.py index e61c3b3278..374bffe94c 100644 --- a/scripts/ci/tests/test_rust_ci_workflow_contract.py +++ b/scripts/ci/tests/test_rust_ci_workflow_contract.py @@ -14,7 +14,6 @@ import importlib.util import json -import os import shutil import sys import tempfile @@ -26,6 +25,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command REPO_ROOT = Path(__file__).resolve().parents[3] @@ -248,7 +248,7 @@ def test_stacked_and_non_master_target_pr_semantics(self) -> None: """Validate merge-base aware impact planning for stacked/non-master PRs.""" with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as out_dir: env = { - **{k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, + **git_env.drop(), "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", diff --git a/scripts/ci/tests/test_scorecard_gate.py b/scripts/ci/tests/test_scorecard_gate.py index 4a699e8606..b6414de50b 100644 --- a/scripts/ci/tests/test_scorecard_gate.py +++ b/scripts/ci/tests/test_scorecard_gate.py @@ -21,6 +21,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import CommandFailed, CommandResult from scripts.lib.safe_subprocess import run as run_command @@ -494,7 +495,7 @@ def setUp(self) -> None: self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) / "repo" self.root.mkdir() - self.env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + self.env = git_env.drop() self.env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) self.command("init", "-q") self.command("config", "user.name", "Fixture") diff --git a/scripts/ci/tests/test_suite_registry.py b/scripts/ci/tests/test_suite_registry.py index 446c77b21d..2989f6d4de 100644 --- a/scripts/ci/tests/test_suite_registry.py +++ b/scripts/ci/tests/test_suite_registry.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: EUPL-1.2 """Positive, negative and boundary controls for scripts/ci/suite_registry.py (ADR-1528). + Each case builds a throwaway Git repository holding a manifest, an aggregator with a `required` list and a few test files, then runs the registry as a subprocess of this interpreter, as CI does. @@ -11,7 +12,6 @@ from __future__ import annotations import json -import os import shutil import subprocess import sys @@ -19,6 +19,10 @@ import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] REGISTRY = ROOT / "scripts" / "ci" / "suite_registry.py" GIT = shutil.which("git") @@ -70,7 +74,7 @@ def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="suite-registry-") self.addCleanup(self._tmp.cleanup) self.repo = Path(self._tmp.name) - self.env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + self.env = git_env.drop() self.git("init", "-q") def git(self, *args: str) -> None: diff --git a/scripts/ci/tests/test_tidy_changed_route.py b/scripts/ci/tests/test_tidy_changed_route.py index 45e47a8ca8..04adbe3815 100644 --- a/scripts/ci/tests/test_tidy_changed_route.py +++ b/scripts/ci/tests/test_tidy_changed_route.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: EUPL-1.2 """The changed-files clang-tidy routing defers only to a lane that measures the file. + Planted trees exercise both modes of scripts/ci/tidy_changed_route.py: a file no lane measures fails the Tidy Changed job and is named; a file a lane measures is skipped with its lane named; a header reached only through units @@ -21,6 +22,10 @@ import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + SCRIPT = Path(__file__).resolve().parents[1] / "tidy_changed_route.py" GIT = shutil.which("git") or "git" @@ -29,7 +34,7 @@ def clean_environment() -> dict[str, str]: """The environment without GIT_* variables. A git hook exports GIT_INDEX_FILE (absolute in a linked worktree) and GIT_DIR; inherited, they would make the fixture's `git add -A` rewrite the index of the repository running the hook.""" - return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + return git_env.drop() FILES = { diff --git a/scripts/ci/tests/test_tidy_lane_container.py b/scripts/ci/tests/test_tidy_lane_container.py index 183b11c669..efcb69aeea 100644 --- a/scripts/ci/tests/test_tidy_lane_container.py +++ b/scripts/ci/tests/test_tidy_lane_container.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: EUPL-1.2 """The clang-tidy lanes are measured in the dev container (ADR-1471). + Three things can drift apart without anyone seeing it, and each one turns a baseline into numbers no other machine reproduces: @@ -29,6 +30,10 @@ import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] MAKEFILE = ROOT / "Makefile" SCRIPT = ROOT / "scripts/dev/tidy-lane.sh" @@ -261,7 +266,7 @@ def setUp(self) -> None: self.out = self.tmp / "reports" def _git(self, *args: str) -> None: - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env = git_env.drop() git = shutil.which("git") self.assertIsNotNone(git, "git is needed to build the fixture checkout") assert git is not None @@ -270,7 +275,7 @@ def _git(self, *args: str) -> None: ) def _run(self, *args: str, **env: str) -> subprocess.CompletedProcess[str]: - environ = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + environ = git_env.drop() environ.update( PATH=f"{self.docker_dir / 'bin'}{os.pathsep}{environ['PATH']}", FAKE_DOCKER_DIR=str(self.docker_dir), diff --git a/scripts/ci/tests/test_windows_hooks_scratch_origin.py b/scripts/ci/tests/test_windows_hooks_scratch_origin.py index 02a075baac..34a0c8b60d 100644 --- a/scripts/ci/tests/test_windows_hooks_scratch_origin.py +++ b/scripts/ci/tests/test_windows_hooks_scratch_origin.py @@ -15,10 +15,15 @@ import os import shutil import subprocess +import sys import tempfile import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[3] WORKFLOW = ROOT / ".github" / "workflows" / "standards-gate.yml" STEP_NAME = "- name: Test lefthook pre-commit in a scratch clone" @@ -43,7 +48,7 @@ def scratch_git_lines(text: str) -> list[str]: def hook_free_environment() -> dict[str, str]: """The process environment without GIT_* (a hook's GIT_DIR / GIT_INDEX_FILE would point every git command, fixture setup and workflow step included, at the caller's repository).""" - return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + return git_env.drop() def git(cwd: Path, *args: str) -> str: diff --git a/scripts/codegen/tests/test_vmafx_api_cli.py b/scripts/codegen/tests/test_vmafx_api_cli.py index 013de23f29..1d449f26fa 100644 --- a/scripts/codegen/tests/test_vmafx_api_cli.py +++ b/scripts/codegen/tests/test_vmafx_api_cli.py @@ -16,6 +16,7 @@ import io import os import shutil +import sys import tempfile import unittest from contextlib import redirect_stdout @@ -26,6 +27,10 @@ from vmafx_api import changelog, cli from vmafx_api.loader import parse +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + def main_output(argv: list[str]) -> tuple[int, str]: out = io.StringIO() @@ -62,7 +67,7 @@ def setUp(self) -> None: # A git hook exports GIT_DIR / GIT_INDEX_FILE; without this the fixture's # init and commits would act on the caller's repository. The identity # comes from the environment so nothing is written to a git config. - clean = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + clean = git_env.drop() clean.update( GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1", diff --git a/scripts/codegen/tests/test_vmafx_api_gitref.py b/scripts/codegen/tests/test_vmafx_api_gitref.py index c5cd18a3b5..b435cedfdc 100644 --- a/scripts/codegen/tests/test_vmafx_api_gitref.py +++ b/scripts/codegen/tests/test_vmafx_api_gitref.py @@ -15,6 +15,7 @@ import os import shutil import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -22,6 +23,10 @@ from support import ROOT # noqa: F401 -- puts scripts/codegen on sys.path from vmafx_api import gitref +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + GIT = shutil.which("git") or "git" TIMEOUT_S = 60 IDENTITY = { @@ -40,7 +45,7 @@ def git(repo: Path, *args: str) -> None: check=True, capture_output=True, timeout=TIMEOUT_S, - env={**{k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, **IDENTITY}, + env={**git_env.drop(), **IDENTITY}, ) diff --git a/scripts/dev/install_merge_train_guard.py b/scripts/dev/install_merge_train_guard.py index b0e741841a..869162a6d4 100644 --- a/scripts/dev/install_merge_train_guard.py +++ b/scripts/dev/install_merge_train_guard.py @@ -20,6 +20,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import CommandFailed from scripts.lib.safe_subprocess import run as run_command @@ -40,7 +41,7 @@ def git(source: Path, *args: str) -> str: result = run_command( [executable, "-C", str(source), *args], allowed_executables=(executable,), - env={key: value for key, value in os.environ.items() if not key.startswith("GIT_")}, + env=git_env.drop(), capture_output=True, text=True, check=True, diff --git a/scripts/dev/merge_train_guard.py b/scripts/dev/merge_train_guard.py index 88a774f102..b12dbca6c2 100644 --- a/scripts/dev/merge_train_guard.py +++ b/scripts/dev/merge_train_guard.py @@ -29,6 +29,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command REPO = "VMAFx/vmafx" @@ -85,9 +86,8 @@ def environment() -> dict[str, str]: """Do not inherit Git redirection or make dry-run/alternate-file flags.""" return { key: value - for key, value in os.environ.items() - if not key.startswith("GIT_") - and key + for key, value in git_env.drop().items() + if key not in {"MAKEFLAGS", "MFLAGS", "MAKELEVEL", "GNUMAKEFLAGS", "MAKEFILES", "MAKEOVERRIDES"} } diff --git a/scripts/dev/test-resolve-state-md-conflict.py b/scripts/dev/test-resolve-state-md-conflict.py index 967ef36d3d..410de155bd 100755 --- a/scripts/dev/test-resolve-state-md-conflict.py +++ b/scripts/dev/test-resolve-state-md-conflict.py @@ -29,6 +29,10 @@ import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.lib import git_env + ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / "scripts" / "dev" / "resolve-state-md-conflict.py" CHECKER = ROOT / "scripts" / "ci" / "check-state-md-rows.sh" @@ -133,7 +137,7 @@ def relist(text: str, label: str, ids: list[str]) -> str: def clean_environment() -> dict[str, str]: """The caller's environment without any Git state, so no real repo is touched.""" - env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env = git_env.drop() env.update( GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, diff --git a/scripts/dev/tests/test_upstream_pr_sweep.py b/scripts/dev/tests/test_upstream_pr_sweep.py index afdcc1c32b..5517d599c3 100644 --- a/scripts/dev/tests/test_upstream_pr_sweep.py +++ b/scripts/dev/tests/test_upstream_pr_sweep.py @@ -88,6 +88,11 @@ def test_git_environment_keeps_settings_and_drops_locations(self) -> None: "GIT_AUTHOR_NAME": "a", "GIT_SSH_COMMAND": "ssh", "HOME": "/home/x", + # A non-Git variable the tools need: the migration to + # scripts.lib.git_env briefly dropped every non-GIT_* key here, + # which starved git_cmd and build_environment of PATH (spotted in + # review on PR #3339). + "PATH": "/usr/bin:/bin", } with patch.dict(os.environ, caller, clear=True): self.assertEqual( @@ -99,6 +104,7 @@ def test_git_environment_keeps_settings_and_drops_locations(self) -> None: "GIT_AUTHOR_NAME": "a", "GIT_SSH_COMMAND": "ssh", "HOME": "/home/x", + "PATH": "/usr/bin:/bin", }, ) @@ -509,10 +515,11 @@ def test_discover_prs_mocked(self) -> None: def test_negative_unsigned_commit_rejected(self) -> None: empty_signers = self.root / "empty_signers" empty_signers.write_text("", encoding="utf-8") + key_prefix = "GIT_CONFIG_KEY_" index = next( - key[len("GIT_CONFIG_KEY_") :] + key[len(key_prefix) :] for key, value in os.environ.items() - if key.startswith("GIT_CONFIG_KEY_") and value == "gpg.ssh.allowedSignersFile" + if key.startswith(key_prefix) and value == "gpg.ssh.allowedSignersFile" ) os.environ[f"GIT_CONFIG_VALUE_{index}"] = str(empty_signers) diff --git a/scripts/dev/tests/upstream_pr_sweep_fixture.py b/scripts/dev/tests/upstream_pr_sweep_fixture.py index 98ebe40db1..4826431e86 100644 --- a/scripts/dev/tests/upstream_pr_sweep_fixture.py +++ b/scripts/dev/tests/upstream_pr_sweep_fixture.py @@ -15,6 +15,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.dev import upstream_pr_sweep as sweep_mod +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command REPO_ROOT = Path(__file__).resolve().parents[3] @@ -53,7 +54,7 @@ def git_run(cwd: Path, *args: str) -> str: def fixture_environment(root: Path) -> dict[str, str]: """Process environment for one test: no caller GIT_*, identity and signing by env only.""" - clean = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + clean = git_env.drop() settings = ( *FIXTURE_SETTINGS, ("user.signingkey", str(root / "id_ed25519")), diff --git a/scripts/dev/upstream_pr_sweep.py b/scripts/dev/upstream_pr_sweep.py index fc5edfeb5e..09321e59d2 100644 --- a/scripts/dev/upstream_pr_sweep.py +++ b/scripts/dev/upstream_pr_sweep.py @@ -33,6 +33,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci.run_meson_test import sanitize_process_environment +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command MIN_PR_PARTS = 2 @@ -272,12 +273,19 @@ def discover_prs( def git_environment() -> dict[str, str]: - """Return the process environment without any variable that locates a repository.""" + """Return the process environment without any variable that locates a repository. + + The whitelist (``GIT_SETTING_PREFIXES`` minus ``GIT_DROPPED_SETTINGS``) + must be evaluated against the CALLER's environment: the earlier + ``git_env.drop()`` sourcing dropped every ``GIT_*`` key before the + whitelist could re-admit the settings, which starved the fixture identity + and signing configuration out of every git subprocess (spotted in review + on PR #3339). + """ return { key: value - for key, value in os.environ.items() - if not key.startswith("GIT_") - or (key.startswith(GIT_SETTING_PREFIXES) and key not in GIT_DROPPED_SETTINGS) + for key, value in git_env.drop_except(prefixes=("GIT_",), keep=GIT_SETTING_PREFIXES).items() + if key not in GIT_DROPPED_SETTINGS } diff --git a/scripts/docs/agents_index.py b/scripts/docs/agents_index.py index d50650299c..a7e4f0864f 100755 --- a/scripts/docs/agents_index.py +++ b/scripts/docs/agents_index.py @@ -34,7 +34,6 @@ from __future__ import annotations import argparse -import os import posixpath import re import sys @@ -46,6 +45,7 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import run as run_command # noqa: E402 PAGES_DIR = "AGENTS.d" @@ -437,7 +437,7 @@ def directories_from(files: Iterable[str]) -> list[str]: def discover(root: Path) -> list[str]: """List every directory with an ``AGENTS.d/`` that Git does not ignore.""" - environment = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + environment = git_env.drop() result = run_command( ("git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"), allowed_executables=("git",), diff --git a/scripts/docs/agents_migration_check.py b/scripts/docs/agents_migration_check.py index c6a5e0f6a6..a481e4db9a 100755 --- a/scripts/docs/agents_migration_check.py +++ b/scripts/docs/agents_migration_check.py @@ -31,7 +31,6 @@ from __future__ import annotations import argparse -import os import posixpath import re import statistics @@ -45,6 +44,7 @@ sys.path.insert(0, str(ROOT)) from scripts.docs import agents_index # noqa: E402 +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import run as run_command # noqa: E402 HEADING = "heading" @@ -357,7 +357,7 @@ def read_old(ref: str | None, file: Path | None, directory: str, root: Path) -> if file is not None: return file.read_text(encoding="utf-8") - environment = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + environment = git_env.drop() result = run_command( ("git", "-C", str(root), "show", f"{ref}:{directory}/{agents_index.INDEX_NAME}"), allowed_executables=("git",), diff --git a/scripts/docs/credits_lib.py b/scripts/docs/credits_lib.py index 3613919c06..e11fac3cd8 100644 --- a/scripts/docs/credits_lib.py +++ b/scripts/docs/credits_lib.py @@ -13,7 +13,6 @@ from __future__ import annotations import datetime -import os import re from collections import Counter from dataclasses import dataclass @@ -22,6 +21,7 @@ import yaml # type: ignore[import-untyped] # PyYAML ships no stubs in the hook env, as in scripts/ci/check_composite_actions.py +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command KINDS = ( @@ -280,7 +280,7 @@ def swap(match: re.Match[str]) -> str: def tracked_files(root: Path) -> list[str]: """Files Git lists for the checkout: tracked, and untracked but not ignored.""" - env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env = git_env.drop() result = run_command( ("git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"), allowed_executables=("git",), diff --git a/scripts/docs/tests/fixture_repo.py b/scripts/docs/tests/fixture_repo.py index 86bc438b19..ab900e8c14 100644 --- a/scripts/docs/tests/fixture_repo.py +++ b/scripts/docs/tests/fixture_repo.py @@ -4,10 +4,10 @@ from __future__ import annotations -import os import shutil from pathlib import Path +from scripts.lib import git_env from scripts.lib.safe_subprocess import run as run_command @@ -19,7 +19,7 @@ def git_init(root: Path) -> None: (git, "init", "-q", str(root)), allowed_executables=(git,), # Without the hook's GIT_DIR: the fixture, not the caller's repository. - env={key: value for key, value in os.environ.items() if not key.startswith("GIT_")}, + env=git_env.drop(), capture_output=True, check=True, timeout_seconds=60, diff --git a/scripts/docs/tests/test_agents_index.py b/scripts/docs/tests/test_agents_index.py index 7185c39b10..bd1a5b5754 100644 --- a/scripts/docs/tests/test_agents_index.py +++ b/scripts/docs/tests/test_agents_index.py @@ -21,6 +21,7 @@ from scripts.dev import project_modernization_audit as audit # noqa: E402 from scripts.docs import agents_index # noqa: E402 from scripts.docs import agents_migration_check as migration # noqa: E402 +from scripts.lib import git_env # noqa: E402 from scripts.lib.safe_subprocess import run as run_command # noqa: E402 DIRECTORY = "tools/gate" @@ -416,7 +417,7 @@ def test_directories_from_names_only_owners_of_a_pages_directory(self) -> None: self.assertEqual(agents_index.directories_from(names), ["", "a"]) def git(self, *arguments: str) -> None: - environment = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + environment = git_env.drop() run_command( ("git", "-C", str(self.root), *arguments), allowed_executables=("git",), @@ -577,7 +578,7 @@ def test_invalid_sources_exit_65(self) -> None: def test_old_text_can_come_from_a_git_revision(self) -> None: home = {"HOME": str(self.root), "XDG_CONFIG_HOME": str(self.root / ".config")} - environment = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + environment = git_env.drop() environment.update(home) identity = ("-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid") safety = ("-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null") diff --git a/scripts/git-hooks/test-hiss-audit-offline.py b/scripts/git-hooks/test-hiss-audit-offline.py index 1f1576a18a..523e87d3ff 100644 --- a/scripts/git-hooks/test-hiss-audit-offline.py +++ b/scripts/git-hooks/test-hiss-audit-offline.py @@ -21,10 +21,15 @@ import shutil import stat import subprocess +import sys import tempfile import unittest from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.lib import git_env + SCRIPT = Path(__file__).with_name("hiss-audit.sh").resolve() LAUNCHER = Path(__file__).resolve().parents[2] / ".config/lefthook/engine.sh" BASH = Path(shutil.which("bash") or "/bin/bash") @@ -58,7 +63,7 @@ def run_hook(usage: str, workflows: dict[str, str]) -> tuple[int, str, str]: repo = root / "repo" # A git hook exports GIT_DIR and GIT_INDEX_FILE; with them `git init` and the # script under test would act on the caller's repository, not on `repo`. - env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env = git_env.drop() subprocess.run( # noqa: S603 -- resolved git path, fixed arguments, no shell [str(GIT), "init", "-q", str(repo)], check=True, timeout=TIMEOUT_S, env=env ) diff --git a/scripts/git-hooks/test-pre-push-mypy.py b/scripts/git-hooks/test-pre-push-mypy.py index 79636f5033..8d1091140e 100644 --- a/scripts/git-hooks/test-pre-push-mypy.py +++ b/scripts/git-hooks/test-pre-push-mypy.py @@ -15,6 +15,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -76,11 +77,7 @@ def fake_checker_source() -> str: def clean_fixture_environment(mypy: str) -> dict[str, str]: - environment = { - key: value - for key, value in os.environ.items() - if not key.startswith(("GIT_", "PRE_COMMIT_")) - } + environment = git_env.drop(prefixes=("GIT_", "PRE_COMMIT_")) environment.update( GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, @@ -152,9 +149,7 @@ def setUp(self) -> None: self.directory = Path(temporary.name) self.root = self.directory / "repo" self.root.mkdir() - self.environment = { - k: v for k, v in os.environ.items() if not k.startswith(("GIT_", "PRE_COMMIT_")) - } + self.environment = git_env.drop(prefixes=("GIT_", "PRE_COMMIT_")) self.environment.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) self.git("init", "-q", "--initial-branch=master") self.git("config", "user.name", "Fixture") diff --git a/scripts/git-hooks/test-pre-push-pr-body-lint.py b/scripts/git-hooks/test-pre-push-pr-body-lint.py index 02c73a0a03..76832a388a 100644 --- a/scripts/git-hooks/test-pre-push-pr-body-lint.py +++ b/scripts/git-hooks/test-pre-push-pr-body-lint.py @@ -18,11 +18,16 @@ import json import os import shutil +import sys import tempfile import unittest from dataclasses import dataclass from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.lib import git_env + SCRIPT = Path(__file__).with_name("pre-push-pr-body-lint.sh").resolve() EXEMPTION = SCRIPT.parents[2] / "scripts/ci/release-pr-exempt.sh" DEPENDENCY_CLASSIFIER = SCRIPT.parents[2] / "scripts/ci/classify-dependency-pr.sh" @@ -91,11 +96,7 @@ def setUp(self) -> None: validator = self.root / "scripts/ci/validate-pr-body.sh" validator.write_text("#!/usr/bin/env bash\nexit 0\n") validator.chmod(0o700) - self.environment = { - key: value - for key, value in os.environ.items() - if not key.startswith(("GIT_", "GH_", "PRE_COMMIT_")) - } + self.environment = git_env.drop(prefixes=("GIT_", "GH_", "PRE_COMMIT_")) self.environment.update( GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, diff --git a/scripts/githooks/tests/test_install.py b/scripts/githooks/tests/test_install.py index 437465c205..09a28756a7 100644 --- a/scripts/githooks/tests/test_install.py +++ b/scripts/githooks/tests/test_install.py @@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -116,11 +117,7 @@ def setUp(self) -> None: self.bin.mkdir() self.log = self.base / "events.jsonl" # Isolate Git/pre-commit config and caches from the user's checkout. - self.env = { - key: value - for key, value in os.environ.items() - if not key.startswith(("GIT_", "PRE_COMMIT_")) - } + self.env = git_env.drop(prefixes=("GIT_", "PRE_COMMIT_")) self.env.update( GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, @@ -576,7 +573,7 @@ def setUp(self) -> None: self.log = self.repo / "bridge.log" self.bash = shutil.which("bash") or "/bin/bash" self.git = shutil.which("git") or "/usr/bin/git" - self.env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + self.env = git_env.drop() self.env["BRIDGE_LOG"] = str(self.log) run_command( (self.git, "init", "--quiet"), diff --git a/scripts/githooks/tests/test_install_hooks_env.py b/scripts/githooks/tests/test_install_hooks_env.py index 32dfed08d1..9cee5e7efd 100644 --- a/scripts/githooks/tests/test_install_hooks_env.py +++ b/scripts/githooks/tests/test_install_hooks_env.py @@ -42,6 +42,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from scripts.lib import git_env from scripts.lib.safe_subprocess import TextCommandResult from scripts.lib.safe_subprocess import run as run_command @@ -72,8 +73,8 @@ def setUp(self) -> None: self.base = Path(self.temporary.name) self.env = { key: value - for key, value in os.environ.items() - if not key.startswith(("GIT_", "PRE_COMMIT_")) and key != "SKIP" + for key, value in git_env.drop(prefixes=("GIT_", "PRE_COMMIT_")).items() + if key != "SKIP" } self.env.update( GIT_CONFIG_NOSYSTEM="1", diff --git a/scripts/lib/AGENTS.md b/scripts/lib/AGENTS.md index 2d07f7c86b..67f344fe36 100644 --- a/scripts/lib/AGENTS.md +++ b/scripts/lib/AGENTS.md @@ -23,6 +23,7 @@ Nothing in this directory mirrors upstream Netflix/vmaf. Rebase risk | `scratch_program.py` | `core/test/test_stale_text_contract.py`, `scripts/codegen/tests/test_vmafx_api_layout.py`, `test_vmafx_api_options.py` (through `support.py`). | `remove_program()` deletes program test built and ran, before `TemporaryDirectory` cleanup: Windows ARM64 image keeps Defender real-time protection on, fresh program held briefly after exit (WinError 5). Retry only `PermissionError`, only that file, bounded (`ATTEMPTS` x `DELAY_SECONDS`), last error raised; tries `.exe` too. Never `ignore_cleanup_errors=True` instead. Stdlib only. `test_scratch_program.py` plants held unlink on Windows `rmtree` path. | | `http_stand_in.py` | `compat/python-vmaf/tests/test_retrying_download.py`, `scripts/ci/tests/test_fetch_test_yuvs.py`. | `StandIn(statuses, body, bodies)`: loopback server answering planned statuses in order, then 200; `requests` lists paths, `url(path)` / `base` give address. One stand-in for every download-retry test (504 before 200); no second copy in test file. Stdlib only. `test_http_stand_in.py` covers plan order, per-path body, shutdown. | | `safe_subprocess.py` | Python automation under `scripts/`. | Every executable is allowlisted, argv/environment/cwd are validated, captured output and runtime are bounded, and POSIX timeout, output-overflow, and caller-cancellation cleanup own process group. `scripts/__init__.py` plus canonical `scripts.lib.safe_subprocess` imports preserve one runtime/type identity. Do not replace it with raw `subprocess` call or `S603` waiver. | +| `git_env.py` | Every Python script or test that runs Git outside the caller's checkout (`scripts/ci/`, `scripts/dev/`, `scripts/docs/`, `scripts/git-hooks/`, `scripts/githooks/`, `python/test/`). | `drop()` is the twin of `drop-git-env.sh` (removes the caller's `GIT_*`), `hermetic()` the twin of `clean-git-env.sh` (fixture identity + config nulling + maintenance off, parse-checked against the shell file by `test_git_env.py`). `prefixes=` extends one call to sibling namespaces (`GH_`, `PRE_COMMIT_`, `MAKE`). Builders return a new mapping, never mutate. The one inline `GIT_*` filter outside this module is `tools/rc1-tester/image/licensing.py` (copied alone into the image); `scripts/ci/test_git_fixture_isolation.py` refuses every other one (issue #3324). | | `clean-git-env.sh` | Every shell test that builds scratch git repo; `scripts/upstream-watcher/check_ffmpeg_av1_videotoolbox.sh`. | Sourced, never executed. Unsets all `GIT_*`, nulls global/system config, exports `GIT_AUTHOR_*`/`GIT_COMMITTER_*`. Hook exports `GIT_DIR` -> fixture `git config user.*` hits real `.git/config`. Never add `git config user.*`; `scripts/ci/test_git_fixture_isolation.py` scans + runs all under sentinel `GIT_DIR`. | | `drop-git-env.sh` | `clean-git-env.sh`; `scripts/ci/upstream-ffmpeg-compat.sh`, `upstream-gstreamer-compat.sh`. | Sourced, never executed. Unsets all `GIT_*`, nothing else: for script cloning or initialising repository elsewhere without fixture identity. Hook `GIT_DIR` -> `git clone` / `git init` / `git config` write caller's repository. Isolation rule of `scripts/ci/test_git_fixture_isolation.py` accepts either helper as marker; new exception needs file, reason, expiry in `ISOLATION_EXCEPTIONS`. | diff --git a/scripts/lib/git_env.py b/scripts/lib/git_env.py new file mode 100644 index 0000000000..67cf5119d7 --- /dev/null +++ b/scripts/lib/git_env.py @@ -0,0 +1,96 @@ +# Copyright 2026 luoye-cpu +# SPDX-License-Identifier: EUPL-1.2 +"""Environment builders for scripts that run Git outside the caller's checkout. + +Python twins of ``scripts/lib/drop-git-env.sh`` and +``scripts/lib/clean-git-env.sh`` (issue #3324): one implementation of the +GIT_* scrub instead of an inline copy per script. A git hook (pre-commit, +pre-push) exports ``GIT_DIR`` and ``GIT_INDEX_FILE``; with those set, ``git +init``, ``git clone``, ``git config``, ``git add`` and ``git commit`` act on +the CALLER's repository whatever the working directory is. ``drop()`` removes +every ``GIT_*`` variable and changes nothing else — the twin for a script that +clones or initialises a repository elsewhere. ``hermetic()`` adds what +``clean-git-env.sh`` exports: the system and user configuration are ignored, +the fixture identity comes from the environment (no fixture may run ``git +config user.*``), and automatic maintenance is off — a background ``git +maintenance run --auto --detach`` repacks a scratch repository and deletes its +loose objects while the fixture copies or reads them. + +Both builders return a new mapping and never mutate their input; hand the +result to ``subprocess.run(env=...)`` (or through +``scripts.lib.safe_subprocess``), or apply it to the process environment +explicitly. ``scripts/ci/test_git_fixture_isolation.py`` refuses a new inline +``GIT_*`` filter outside this module, so a fix here reaches every script. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping, Sequence + +FIXTURE_NAME = "Fixture" +FIXTURE_EMAIL = "fixture@example.invalid" + + +def drop( + env: Mapping[str, str] | None = None, prefixes: Sequence[str] = ("GIT_",) +) -> dict[str, str]: + """Return `env` (default the process environment) without the prefixed variables. + + `prefixes` extends the scrub to sibling namespaces (``GH_``, ``PRE_COMMIT_``, + ``MAKE``) a caller also must not inherit; the default is exactly the shell + twin's GIT_* set. + """ + source = os.environ if env is None else env + return {key: value for key, value in source.items() if not key.startswith(tuple(prefixes))} + + +def drop_except( + env: Mapping[str, str] | None = None, + prefixes: Sequence[str] = ("GIT_",), + keep: Sequence[str] = (), +) -> dict[str, str]: + """``drop()`` that re-admits the whitelisted sub-namespaces in ``keep``. + + For scripts that must keep a whitelist of settings while still scrubbing + everything else in the namespace: a key survives when it carries none of + ``prefixes`` or when it starts with one of ``keep``. The upstream-pr sweep, + for one, keeps the fixture's ``GIT_CONFIG_*`` / ``GIT_AUTHOR_*`` settings + but must not inherit a hook's ``GIT_DIR``. Keys are never read twice from + ``env`` and the input mapping is never mutated, like ``drop()``. + """ + source = os.environ if env is None else env + prefixes_t = tuple(prefixes) + keep_t = tuple(keep) + return { + key: value + for key, value in source.items() + if not key.startswith(prefixes_t) or key.startswith(keep_t) + } + + +def hermetic(env: Mapping[str, str] | None = None) -> dict[str, str]: + """``drop()`` plus the ``clean-git-env.sh`` fixture environment. + + The keys and values mirror ``scripts/lib/clean-git-env.sh`` export for + export; ``scripts/lib/test_git_env.py`` parses that file and fails when the + two drift apart. ``GIT_CONFIG_GLOBAL`` is the platform null device rather + than the shell file's literal ``/dev/null``: git accepts both, and the + Python side runs natively on Windows too. + """ + return { + **drop(env), + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_COUNT": "3", + "GIT_CONFIG_KEY_0": "init.defaultBranch", + "GIT_CONFIG_VALUE_0": "master", + "GIT_CONFIG_KEY_1": "maintenance.auto", + "GIT_CONFIG_VALUE_1": "false", + "GIT_CONFIG_KEY_2": "gc.auto", + "GIT_CONFIG_VALUE_2": "0", + "GIT_AUTHOR_NAME": FIXTURE_NAME, + "GIT_AUTHOR_EMAIL": FIXTURE_EMAIL, + "GIT_COMMITTER_NAME": FIXTURE_NAME, + "GIT_COMMITTER_EMAIL": FIXTURE_EMAIL, + } diff --git a/scripts/lib/test_git_env.py b/scripts/lib/test_git_env.py new file mode 100644 index 0000000000..08ec4dab97 --- /dev/null +++ b/scripts/lib/test_git_env.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# Copyright 2026 luoye-cpu +# SPDX-License-Identifier: EUPL-1.2 +"""Controls for the GIT_* environment builders and their shell twins.""" + +from __future__ import annotations + +import os +import re +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.lib.git_env import ( + FIXTURE_EMAIL, + FIXTURE_NAME, + drop, + drop_except, + hermetic, +) + +ROOT = Path(__file__).resolve().parents[2] +SHELL_CLEAN = ROOT / "scripts/lib/clean-git-env.sh" + +EXPORT = re.compile(r"(?m)^export (GIT_[A-Z0-9_]+)=(\S+)\s*$") + + +def strip_quotes(parsed: dict[str, str]) -> dict[str, str]: + """Drop the quoting the shell file wraps its values in.""" + return {key: value.strip('"') for key, value in parsed.items()} + + +class DropTest(unittest.TestCase): + def test_drop_removes_every_git_variable_and_keeps_the_rest(self) -> None: + source = { + "GIT_DIR": "/caller/.git", + "GIT_INDEX_FILE": "/caller/.git/index", + "GIT_CONFIG_COUNT": "9", + "PATH": "/bin", + "LANG": "C.UTF-8", + } + self.assertEqual(drop(source), {"PATH": "/bin", "LANG": "C.UTF-8"}) + + def test_drop_defaults_to_the_process_environment_without_mutating_it(self) -> None: + os.environ["GIT_DIR_TEST_PROBE"] = "/probe" + try: + self.assertNotIn("GIT_DIR_TEST_PROBE", drop()) + self.assertEqual(os.environ.get("GIT_DIR_TEST_PROBE"), "/probe") + finally: + del os.environ["GIT_DIR_TEST_PROBE"] + + def test_drop_never_mutates_its_input(self) -> None: + source = {"GIT_DIR": "/x", "HOME": "/h"} + drop(source) + self.assertEqual(source, {"GIT_DIR": "/x", "HOME": "/h"}) + + +class DropExceptTest(unittest.TestCase): + """The whitelist twin: scrub the namespace, re-admit the settings.""" + + def test_drop_except_re_admits_only_the_keep_prefixes(self) -> None: + source = { + "GIT_DIR": "/caller/.git", + "GIT_INDEX_FILE": "/caller/.git/index", + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "user.name", + "GIT_AUTHOR_NAME": "a", + "PATH": "/bin", + } + self.assertEqual( + drop_except(source, prefixes=("GIT_",), keep=("GIT_CONFIG_", "GIT_AUTHOR_")), + { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "user.name", + "GIT_AUTHOR_NAME": "a", + "PATH": "/bin", + }, + ) + + def test_drop_except_with_empty_keep_equals_drop(self) -> None: + source = {"GIT_DIR": "/x", "GIT_CONFIG_COUNT": "1", "HOME": "/h"} + self.assertEqual(drop_except(source), drop(source)) + self.assertEqual(drop_except(source, prefixes=("GIT_",), keep=()), drop(source)) + + def test_drop_except_reads_each_key_once_and_never_mutates_its_input(self) -> None: + source = {"GIT_DIR": "/x", "GIT_CONFIG_KEY_0": "k", "HOME": "/h"} + snapshot = dict(source) + drop_except(source, keep=("GIT_CONFIG_",)) + self.assertEqual(source, snapshot) + + def test_drop_except_defaults_to_the_process_environment(self) -> None: + os.environ["GIT_DIR_TEST_PROBE"] = "/probe" + os.environ["GIT_CONFIG_TEST_PROBE"] = "k" + try: + built = drop_except(keep=("GIT_CONFIG_",)) + self.assertNotIn("GIT_DIR_TEST_PROBE", built) + self.assertEqual(built.get("GIT_CONFIG_TEST_PROBE"), "k") + finally: + del os.environ["GIT_DIR_TEST_PROBE"] + del os.environ["GIT_CONFIG_TEST_PROBE"] + + +class HermeticTest(unittest.TestCase): + def test_hermetic_drops_the_caller_state_and_supplies_the_fixture_identity(self) -> None: + built = hermetic( + { + "GIT_DIR": "/caller/.git", + "GIT_AUTHOR_EMAIL": "caller@example.invalid", + "PATH": "/bin", + } + ) + self.assertNotIn("GIT_DIR", built) + self.assertEqual(built["PATH"], "/bin") + self.assertEqual(built["GIT_AUTHOR_EMAIL"], FIXTURE_EMAIL) + self.assertEqual(built["GIT_COMMITTER_NAME"], FIXTURE_NAME) + self.assertEqual(built["GIT_CONFIG_GLOBAL"], os.devnull) + self.assertEqual(built["GIT_CONFIG_NOSYSTEM"], "1") + self.assertEqual(built["GIT_CONFIG_COUNT"], "3") + self.assertEqual(built["GIT_CONFIG_VALUE_1"], "false") + self.assertEqual(built["GIT_CONFIG_VALUE_2"], "0") + + def test_hermetic_never_mutates_its_input(self) -> None: + source = {"GIT_DIR": "/x"} + hermetic(source) + self.assertEqual(source, {"GIT_DIR": "/x"}) + + +class ShellTwinDriftTest(unittest.TestCase): + """The Python hermetic() must export exactly what clean-git-env.sh exports.""" + + def test_hermetic_matches_the_shell_exports(self) -> None: + shell = strip_quotes(dict(EXPORT.findall(SHELL_CLEAN.read_text(encoding="utf-8")))) + self.assertTrue(shell, "the export lines moved: fix this test's parser") + built = hermetic({}) + self.assertEqual( + set(built) - {"PATH"}, + set(shell), + "hermetic() and clean-git-env.sh export different GIT_* sets", + ) + for key, value in shell.items(): + # The shell file spells the null device /dev/null (bash runs under MSYS); + # git accepts the platform device equally, so the twin uses os.devnull. + expected = os.devnull if value == "/dev/null" else value + self.assertEqual(built[key], expected, key) + + def test_the_shell_fixture_identity_matches_the_python_constants(self) -> None: + shell = strip_quotes(dict(EXPORT.findall(SHELL_CLEAN.read_text(encoding="utf-8")))) + self.assertEqual(shell["GIT_AUTHOR_NAME"], FIXTURE_NAME) + self.assertEqual(shell["GIT_AUTHOR_EMAIL"], FIXTURE_EMAIL) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/upstream-watcher/tests/test_check_netflix_unported.py b/scripts/upstream-watcher/tests/test_check_netflix_unported.py index 2bebac399c..e1ac8fd80c 100644 --- a/scripts/upstream-watcher/tests/test_check_netflix_unported.py +++ b/scripts/upstream-watcher/tests/test_check_netflix_unported.py @@ -8,10 +8,15 @@ import json import os import subprocess +import sys from pathlib import Path import pytest +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from scripts.lib import git_env + SCRIPT_PATH = Path(__file__).resolve().parents[1] / "check_netflix_unported.py" SPEC = importlib.util.spec_from_file_location("check_netflix_unported", SCRIPT_PATH) if SPEC is None or SPEC.loader is None: @@ -29,7 +34,7 @@ def clean_git_environment() -> dict[str, str]: """Return the caller's environment without keys that start with "GIT_".""" - environment = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + environment = git_env.drop() environment.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) return environment @@ -39,7 +44,8 @@ def clean_git_environment() -> dict[str, str]: def setup_module() -> None: """Keep a hook's GIT_* away from the script's own git calls.""" - for key in [key for key in os.environ if key.startswith("GIT_")]: + clean = git_env.drop() + for key in [key for key in os.environ if key not in clean]: SAVED_GIT_ENVIRONMENT[key] = os.environ.pop(key)