diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index 9c33d96..e2acef6 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -21,7 +21,6 @@ import re import shutil import signal -import socket import subprocess import sys from collections import OrderedDict @@ -241,15 +240,22 @@ def _git(directory: str, *args: str, echo: bool = True) -> subprocess.CompletedP return result +def _missing_identity(directory: str) -> dict[str, str]: + """The git identity to commit with where none is configured (a fresh machine), for what git + lacks, by config key. No email, as the hub's Build API commits (Nova's getGitDataFromRequest).""" + if not shutil.which("git"): + return {} + fallback = {"user.name": "CFEngine Policy Builder", "user.email": ""} + return { + key: value for key, value in fallback.items() if not _git(directory, "config", key, echo=False).stdout.strip() + } + + def _git_commit_all(directory: str) -> None: for args in (("init", "--quiet"), ("add", "--all")): if _git(directory, *args).returncode != 0: raise InitFailed(f"git {args[0]} failed") - # Same fallback identity cfbs uses when git has none configured. - identity = [] - for key, fallback in (("user.name", "cfbs"), ("user.email", f"cfbs@{socket.gethostname()}")): - if not _git(directory, "config", key, echo=False).stdout.strip(): - identity += ["-c", f"{key}={fallback}"] + identity = [arg for key, value in _missing_identity(directory).items() for arg in ("-c", f"{key}={value}")] if _git(directory, *identity, "commit", "--quiet", "-m", INIT_COMMIT_MESSAGE).returncode != 0: raise InitFailed("git commit failed") @@ -317,11 +323,28 @@ def _run_cfbs(directory: str, argv: list[str], what: str) -> None: from cfbs.cfbs_config import CFBSConfig CFBSConfig.instance = None # process-wide singleton: each run reads cfbs.json afresh - with _cfbs_session(directory, ["--non-interactive", *argv]) as tee: - try: - code = cfbs.main.main() - except SystemExit as exit: - code = exit.code if isinstance(exit.code, int) else 1 + # cfbs commits each change in a git project, and git refuses to without an identity: it + # left the change staged, so the next save "worked". The same fallback as the first commit. + identity = _missing_identity(directory) + names = { + "user.name": ("GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"), + "user.email": ("GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL"), + } + environment = {variable: value for key, value in identity.items() for variable in names[key]} + saved = {variable: os.environ.get(variable) for variable in environment} + os.environ.update(environment) + try: + with _cfbs_session(directory, ["--non-interactive", *argv]) as tee: + try: + code = cfbs.main.main() + except SystemExit as exit: + code = exit.code if isinstance(exit.code, int) else 1 + finally: + for variable, value in saved.items(): + if value is None: + os.environ.pop(variable, None) + else: + os.environ[variable] = value if code == 0: return output = "".join(tee.chunks) diff --git a/python/tests/test_cfpb_backend.py b/python/tests/test_cfpb_backend.py index c2b906d..a87ec83 100644 --- a/python/tests/test_cfpb_backend.py +++ b/python/tests/test_cfpb_backend.py @@ -212,7 +212,7 @@ def test_init_rejects_malformed_content(tmp_path: Path, monkeypatch: pytest.Monk @needs_git -def test_init_falls_back_to_the_cfbs_git_identity(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): +def test_init_falls_back_to_the_policy_builder_git_identity(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") for variable in ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL"): @@ -222,7 +222,7 @@ def test_init_falls_back_to_the_cfbs_git_identity(tmp_path: Path, monkeypatch: p assert code == 0, stderr [author] = _log(project, "%an <%ae>") - assert author.startswith("cfbs " def test_init_refuses_a_non_empty_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): @@ -513,3 +513,31 @@ def test_add_build_modules_adds_them_with_cfbs_and_writes_their_answers(tmp_path assert json.loads((project / "tiny-module" / "input.json").read_text()) == [{**question, "response": "hi"}] with pytest.raises(InitFailed, match="Invalid module name"): add_build_modules(str(project), [{"name": "../evil"}]) + + +@pytest.mark.skipif(shutil.which("git") is None, reason="needs git") +def test_cfbs_commits_with_a_fallback_identity_where_git_has_none(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # A fresh machine: no user.name or user.email anywhere git looks. + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + for variable in ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"): + monkeypatch.delenv(variable, raising=False) + project = tmp_path / "project" + project.mkdir() + (project / "cfbs.json").write_text('{"name": "p", "type": "policy-set", "git": true, "build": []}') + subprocess.run(["git", "init", "--quiet"], cwd=project, check=True) + seen = {} + + def cfbs_main(): + seen.update({key: os.environ.get(key) for key in ("GIT_AUTHOR_NAME", "GIT_COMMITTER_EMAIL")}) + return 0 + + monkeypatch.setattr("cfbs.main.main", cfbs_main) + + cfpb_backend._run_cfbs(str(project), ["add", "example"], "example") + + assert seen["GIT_AUTHOR_NAME"] == "CFEngine Policy Builder" + assert seen["GIT_COMMITTER_EMAIL"] == "" + # Only while cfbs runs. + assert "GIT_AUTHOR_NAME" not in os.environ