Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions implementations/python/tests/test_issue_1473_nul_path_listings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""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
# 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", "new\nline.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 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)
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
4 changes: 2 additions & 2 deletions tools/check_schema_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down
15 changes: 8 additions & 7 deletions tools/nox_support/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,26 +93,27 @@ 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"


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:
Expand Down Expand Up @@ -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]:
Expand Down
13 changes: 6 additions & 7 deletions tools/policy/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading