From 765382782aeade698f1e24316e1fa5c7e5171ec6 Mon Sep 17 00:00:00 2001 From: Yernat Yestekov <2068106+doublewhy@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:38:39 -0700 Subject: [PATCH 1/2] ci(policy): read git path listings with -z so quoted names reach the gates Without -z, git quotes path names that contain a byte above 0x80 under the default core.quotePath, a control character, a double quote or a backslash. The nox runner dropped such names from the hygiene selection, tools/policy/common.changed_paths handed git's quoted text to the repository policy and JSON artifact checks, and the schema publication base listing dropped them. List with -z and split on NUL in all three, as check_requirement_governance.py and verification_plan.py already do. changed_paths now runs one listing for its three comparisons. --- .../test_issue_1473_nul_path_listings.py | 78 +++++++++++++++++++ tools/check_schema_publication.py | 4 +- tools/nox_support/runner.py | 15 ++-- tools/policy/common.py | 13 ++-- 4 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 implementations/python/tests/test_issue_1473_nul_path_listings.py diff --git a/implementations/python/tests/test_issue_1473_nul_path_listings.py b/implementations/python/tests/test_issue_1473_nul_path_listings.py new file mode 100644 index 000000000..c86093941 --- /dev/null +++ b/implementations/python/tests/test_issue_1473_nul_path_listings.py @@ -0,0 +1,78 @@ +"""Git path listings that feed the gates keep the names git quotes (#1473).""" + +from __future__ import annotations + +import importlib +import os +import subprocess +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest +from tools import check_schema_publication +from tools.policy.common import changed_paths + +# Without -z, git quotes a name with a byte above 0x80 (under the default core.quotePath=true), a control character +# or a double quote, so the gates drop it or receive the quoted text. Git lists the plain name verbatim either way. +_SCHEMAS = "contracts/schemas" +_NAMES = sorted(f"{_SCHEMAS}/{name}" for name in ("naïve.json", "tab\tname.json", '"quoted".json', "plain.json")) +_CHANGE_MODES: dict[str, dict[str, object]] = { + "staged": {"staged": True}, + "base revision": {"base_rev": "HEAD~1"}, + "working tree": {}, +} + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +@pytest.fixture +def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A real repository with one base commit and the four names staged on top of it.""" + + # Use git's built-in defaults, as CI runners do, whatever the developer's global or system configuration says. + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.email", "tests@example.invalid") + _git(tmp_path, "config", "user.name", "Tests") + (tmp_path / "README.md").write_text("base\n", encoding="utf-8") + _git(tmp_path, "add", "README.md") + _git(tmp_path, "commit", "-q", "-m", "base") + (tmp_path / _SCHEMAS).mkdir(parents=True) + for name in _NAMES: + (tmp_path / name).write_text("{}\n", encoding="utf-8") + _git(tmp_path, "add", _SCHEMAS) + return tmp_path + + +@pytest.fixture +def runner(repo: Path, monkeypatch: pytest.MonkeyPatch) -> ModuleType: + """Import the nox runner against a stand-in nox (the project env has no nox), rooted at ``repo``.""" + + monkeypatch.setitem(sys.modules, "nox", SimpleNamespace(Session=object)) + # Registering the key makes monkeypatch remove the stand-in import afterward. + monkeypatch.setitem(sys.modules, "tools.nox_support.runner", None) + monkeypatch.delitem(sys.modules, "tools.nox_support.runner") + module = importlib.import_module("tools.nox_support.runner") + monkeypatch.setattr(module, "REPO_ROOT", repo) + return module + + +@pytest.mark.parametrize("mode", list(_CHANGE_MODES)) +def test_changed_path_listings_keep_names_git_quotes(repo: Path, runner: ModuleType, mode: str) -> None: + if mode == "base revision": + _git(repo, "commit", "-q", "-m", "names") + options = _CHANGE_MODES[mode] + + assert sorted(changed_paths(repo, **options)) == _NAMES + assert sorted(runner._changed_paths(**options)) == _NAMES + + +def test_repository_listings_keep_names_git_quotes(repo: Path, runner: ModuleType) -> None: + _git(repo, "commit", "-q", "-m", "names") + + assert sorted(runner._tracked_repo_paths()) == ["README.md", *_NAMES] + assert check_schema_publication._git_paths(repo, "HEAD", _SCHEMAS) == _NAMES diff --git a/tools/check_schema_publication.py b/tools/check_schema_publication.py index b22119f77..aeabf5d1e 100644 --- a/tools/check_schema_publication.py +++ b/tools/check_schema_publication.py @@ -220,14 +220,14 @@ def _git_show(repo_root: Path, gitref: str) -> str | None: def _git_paths(repo_root: Path, revision: str, directory: str) -> list[str] | None: proc = subprocess.run( - ["git", "ls-tree", "-r", "--name-only", revision, "--", directory], + ["git", "ls-tree", "-r", "--name-only", "-z", revision, "--", directory], cwd=repo_root, capture_output=True, text=True, ) if proc.returncode != 0: return None - return sorted(path for path in proc.stdout.splitlines() if path.endswith(".json")) + return sorted(path for path in proc.stdout.split("\0") if path.endswith(".json")) def _manifest_entries(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: diff --git a/tools/nox_support/runner.py b/tools/nox_support/runner.py index b7dc6e690..e34d43f88 100644 --- a/tools/nox_support/runner.py +++ b/tools/nox_support/runner.py @@ -93,15 +93,16 @@ def _run( session.run(*args, external=True, silent=silent, env=env) -def _git_lines(*args: str) -> list[str]: +def _git_paths(command: str, *options: str) -> list[str]: + """Run a git path listing with -z, which prints names verbatim that git would otherwise quote (#1473).""" proc = subprocess.run( - ["git", *args], + ["git", command, "-z", *options], cwd=REPO_ROOT, text=True, capture_output=True, check=True, ) - return [line.strip() for line in proc.stdout.splitlines() if line.strip()] + return [path for path in proc.stdout.split("\0") if path] _EXCLUDE_DELETED_FILTER = "--diff-filter=d" @@ -109,10 +110,10 @@ def _git_lines(*args: str) -> list[str]: def _changed_paths(*, staged: bool = False, base_rev: str | None = None) -> list[str]: if staged: - return _normalize_paths(_git_lines("diff", "--name-only", _EXCLUDE_DELETED_FILTER, "--cached")) + return _normalize_paths(_git_paths("diff", "--name-only", _EXCLUDE_DELETED_FILTER, "--cached")) if base_rev: - return _normalize_paths(_git_lines("diff", "--name-only", _EXCLUDE_DELETED_FILTER, base_rev, "HEAD")) - return _normalize_paths(_git_lines("diff", "--name-only", _EXCLUDE_DELETED_FILTER, "HEAD")) + return _normalize_paths(_git_paths("diff", "--name-only", _EXCLUDE_DELETED_FILTER, base_rev, "HEAD")) + return _normalize_paths(_git_paths("diff", "--name-only", _EXCLUDE_DELETED_FILTER, "HEAD")) def _sync_project(session: nox.Session) -> None: @@ -301,7 +302,7 @@ def _parse_hygiene_posargs(posargs: Sequence[str], *, default_all_files: bool) - def _tracked_repo_paths() -> list[str]: - return _normalize_paths(_git_lines("ls-files", "--cached", "--others", "--exclude-standard")) + return _normalize_paths(_git_paths("ls-files", "--cached", "--others", "--exclude-standard")) def _normalize_paths(paths: Iterable[str]) -> list[str]: diff --git a/tools/policy/common.py b/tools/policy/common.py index ae9c2419e..62c9c99d8 100644 --- a/tools/policy/common.py +++ b/tools/policy/common.py @@ -101,15 +101,14 @@ def changed_paths( base_rev: str | None = None, ) -> list[str]: if staged: - output = run_git(["diff", "--name-only", "--diff-filter=d", "--cached"], repo_root=repo_root) + comparison = ["--cached"] elif base_rev: - output = run_git( - ["diff", "--name-only", "--diff-filter=d", base_rev, "HEAD"], - repo_root=repo_root, - ) + comparison = [base_rev, "HEAD"] else: - output = run_git(["diff", "--name-only", "--diff-filter=d", "HEAD"], repo_root=repo_root) - return [line.strip() for line in output.splitlines() if line.strip()] + comparison = ["HEAD"] + # -z prints names verbatim that git would otherwise quote (#1473). + output = run_git(["diff", "--name-only", "--diff-filter=d", "-z", *comparison], repo_root=repo_root) + return [path for path in output.split("\0") if path] def path_matches_prefix(path: str, prefix: str) -> bool: From 68dc047446af864e3775e3deab5423407f08dca8 Mon Sep 17 00:00:00 2001 From: Yernat Yestekov <2068106+doublewhy@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:04:06 -0700 Subject: [PATCH 2/2] test(policy): list a name with a newline in the nul path listing test Without -z, git quotes a newline in a name as it does any other control character. The test now covers such a name next to the tab, non-ASCII and double-quote names; dev's three helpers fail on it alone. --- .../python/tests/test_issue_1473_nul_path_listings.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/implementations/python/tests/test_issue_1473_nul_path_listings.py b/implementations/python/tests/test_issue_1473_nul_path_listings.py index c86093941..85fd4f04c 100644 --- a/implementations/python/tests/test_issue_1473_nul_path_listings.py +++ b/implementations/python/tests/test_issue_1473_nul_path_listings.py @@ -14,9 +14,12 @@ from tools.policy.common import changed_paths # Without -z, git quotes a name with a byte above 0x80 (under the default core.quotePath=true), a control character -# or a double quote, so the gates drop it or receive the quoted text. Git lists the plain name verbatim either way. +# such as a tab or a newline, or a double quote, so the gates drop it or receive the quoted text. Git lists the plain +# name verbatim either way. _SCHEMAS = "contracts/schemas" -_NAMES = sorted(f"{_SCHEMAS}/{name}" for name in ("naïve.json", "tab\tname.json", '"quoted".json', "plain.json")) +_NAMES = sorted( + f"{_SCHEMAS}/{name}" for name in ("naïve.json", "tab\tname.json", "new\nline.json", '"quoted".json', "plain.json") +) _CHANGE_MODES: dict[str, dict[str, object]] = { "staged": {"staged": True}, "base revision": {"base_rev": "HEAD~1"}, @@ -30,7 +33,7 @@ def _git(repo: Path, *args: str) -> None: @pytest.fixture def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """A real repository with one base commit and the four names staged on top of it.""" + """A real repository with one base commit and the five names staged on top of it.""" # Use git's built-in defaults, as CI runners do, whatever the developer's global or system configuration says. monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull)