diff --git a/iris/analysis/code_provenance.py b/iris/analysis/code_provenance.py index ced14ed..a36df47 100644 --- a/iris/analysis/code_provenance.py +++ b/iris/analysis/code_provenance.py @@ -23,6 +23,8 @@ import re import subprocess + +from iris.shell import git_env from collections import defaultdict from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -198,6 +200,7 @@ def _blame_file_ages( capture_output=True, text=True, timeout=BLAME_TIMEOUT_SECONDS, + env=git_env(), ) if result.returncode != 0: return None diff --git a/iris/analysis/durability.py b/iris/analysis/durability.py index 54c32f9..f27b6b1 100644 --- a/iris/analysis/durability.py +++ b/iris/analysis/durability.py @@ -21,6 +21,8 @@ import re import subprocess + +from iris.shell import git_env from collections import defaultdict from dataclasses import dataclass, field from datetime import datetime, timezone @@ -255,6 +257,7 @@ def _run_blame(repo_path: str, file_path: str) -> list[str] | None: capture_output=True, text=True, timeout=BLAME_TIMEOUT_SECONDS, + env=git_env(), ) if result.returncode != 0: return None diff --git a/iris/analysis/priming_detector.py b/iris/analysis/priming_detector.py index e6496de..c24c765 100644 --- a/iris/analysis/priming_detector.py +++ b/iris/analysis/priming_detector.py @@ -20,6 +20,8 @@ import os import subprocess + +from iris.shell import git_env from dataclasses import dataclass from datetime import datetime @@ -147,6 +149,7 @@ def _get_introduction_date(repo_path: str, file_path: str) -> datetime | None: capture_output=True, text=True, timeout=10, + env=git_env(), ) if result.returncode != 0 or not result.stdout.strip(): return None diff --git a/iris/analysis/repo_kind.py b/iris/analysis/repo_kind.py index 2a99a1c..0319a36 100644 --- a/iris/analysis/repo_kind.py +++ b/iris/analysis/repo_kind.py @@ -26,6 +26,8 @@ import os import subprocess + +from iris.shell import git_env from enum import Enum @@ -147,6 +149,7 @@ def _tracked_files(repo_path: str) -> list[str]: errors="surrogateescape", check=True, timeout=60, + env=git_env(), ) except (subprocess.SubprocessError, OSError): return [] diff --git a/iris/cli.py b/iris/cli.py index c7107e7..ae7b7be 100644 --- a/iris/cli.py +++ b/iris/cli.py @@ -8,6 +8,7 @@ from collections.abc import Callable from datetime import datetime, timedelta, timezone +from iris.shell import git_env from iris.i18n import get_strings, SUPPORTED_LANGS from iris.platform.telemetry import span, record_metric, record_counter, record_duration, flush from iris.ingestion import window_cache @@ -243,6 +244,7 @@ def _git_remote_url(repo_path: str) -> str | None: result = subprocess.run( ["git", "-C", repo_path, "remote", "get-url", "origin"], capture_output=True, text=True, check=True, + env=git_env(), ) url = result.stdout.strip() except (subprocess.CalledProcessError, FileNotFoundError): @@ -942,6 +944,7 @@ def _run_pr(argv: list[str]) -> None: result = subprocess.run( ["gh", "pr", "view", "--json", "number", "-q", ".number"], capture_output=True, text=True, check=True, cwd=repo_path, + env=git_env(), ) pr_number = int(result.stdout.strip()) except (subprocess.CalledProcessError, ValueError): @@ -982,6 +985,7 @@ def _run_pr(argv: list[str]) -> None: subprocess.run( ["gh", "pr", "comment", str(pr_number), "--body", markdown], check=True, cwd=repo_path, + env=git_env(), ) print(f"Comment posted on PR #{pr_number}.", file=sys.stderr) except subprocess.CalledProcessError: @@ -1170,6 +1174,7 @@ def _hook_file_is_tracked(repo_path: str, hook_file: str) -> bool: cwd=repo_path, capture_output=True, timeout=10, + env=git_env(), ) except (OSError, subprocess.SubprocessError): return False @@ -1183,6 +1188,7 @@ def _hook_file_is_tracked(repo_path: str, hook_file: str) -> bool: capture_output=True, text=True, timeout=10, + env=git_env(), ) if top.returncode != 0: return False diff --git a/iris/hooks/manager.py b/iris/hooks/manager.py index 6c93285..d5f0937 100644 --- a/iris/hooks/manager.py +++ b/iris/hooks/manager.py @@ -27,6 +27,7 @@ import stat import subprocess import tempfile +from iris.shell import git_env HOOK_NAME = "prepare-commit-msg" POST_COMMIT_HOOK_NAME = "post-commit" @@ -130,6 +131,7 @@ def _git(repo_path: str, *args: str) -> str | None: capture_output=True, text=True, timeout=_PROBE_TIMEOUT_SECONDS, + env=git_env(), ) except (OSError, subprocess.SubprocessError): return None diff --git a/iris/ingestion/diff_reader.py b/iris/ingestion/diff_reader.py index 0a58f19..67f2f7c 100644 --- a/iris/ingestion/diff_reader.py +++ b/iris/ingestion/diff_reader.py @@ -13,6 +13,8 @@ import re import subprocess + +from iris.shell import git_env from dataclasses import dataclass from iris.models.commit import Commit @@ -80,6 +82,7 @@ def read_commit_diff(repo_path: str, commit_hash: str) -> CommitDiff | None: capture_output=True, text=True, timeout=DIFF_TIMEOUT_SECONDS, + env=git_env(), ) if result.returncode != 0: return None diff --git a/iris/ingestion/git_reader.py b/iris/ingestion/git_reader.py index 678f8f0..19149e1 100644 --- a/iris/ingestion/git_reader.py +++ b/iris/ingestion/git_reader.py @@ -11,6 +11,8 @@ import re import subprocess + +from iris.shell import git_env from datetime import datetime, timedelta, timezone from iris.models.commit import Commit, FileChange @@ -78,6 +80,7 @@ def read_commits( capture_output=True, text=True, check=True, + env=git_env(), ) except subprocess.CalledProcessError as exc: raise RuntimeError(f"git log failed for {repo_path}: {exc}\n{exc.stderr}") from exc @@ -99,6 +102,7 @@ def _has_commits(repo_path: str) -> bool: ["git", "-C", repo_path, "rev-parse", "--verify", "--quiet", "HEAD"], capture_output=True, text=True, + env=git_env(), ) if result.returncode == 0: return True @@ -140,6 +144,7 @@ def read_pr_commits( capture_output=True, text=True, check=True, + env=git_env(), ) except subprocess.CalledProcessError: return [] diff --git a/iris/ingestion/github_reader.py b/iris/ingestion/github_reader.py index 2fcd1e3..0432caa 100644 --- a/iris/ingestion/github_reader.py +++ b/iris/ingestion/github_reader.py @@ -18,6 +18,8 @@ import re import shutil import subprocess + +from iris.shell import git_env from datetime import datetime, timedelta, timezone from iris.ingestion import window_cache @@ -39,6 +41,7 @@ def detect_github_remote(repo_path: str) -> str | None: capture_output=True, text=True, check=True, + env=git_env(), ) except (subprocess.CalledProcessError, FileNotFoundError): return None @@ -231,6 +234,7 @@ def _fetch_pr_enrichment_graphql( try: result = subprocess.run( args, capture_output=True, text=True, check=True, + env=git_env(), ) except (subprocess.CalledProcessError, FileNotFoundError): return by_pr @@ -296,6 +300,7 @@ def _gh_pr_list(nwo: str, fields: str, limit: int, gh_state: str) -> list[dict] capture_output=True, text=True, check=True, + env=git_env(), ) except (subprocess.CalledProcessError, FileNotFoundError): return None @@ -396,6 +401,7 @@ def read_single_pr(repo_path: str, pr_number: int) -> PullRequest | None: capture_output=True, text=True, check=True, + env=git_env(), ) except (subprocess.CalledProcessError, FileNotFoundError): return None diff --git a/iris/platform/config.py b/iris/platform/config.py index 0d04c1b..3fa9f31 100644 --- a/iris/platform/config.py +++ b/iris/platform/config.py @@ -2,6 +2,7 @@ import json import os +from iris.shell import git_env CONFIG_DIR = os.path.expanduser("~/.iris") CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") @@ -66,6 +67,7 @@ def get_github_user() -> str | None: result = subprocess.run( ["gh", "api", "user", "-q", ".login"], capture_output=True, text=True, timeout=5, + env=git_env(), ) if result.returncode == 0 and result.stdout.strip(): user = result.stdout.strip() @@ -78,6 +80,7 @@ def get_github_user() -> str | None: result = subprocess.run( ["git", "config", "user.email"], capture_output=True, text=True, timeout=5, + env=git_env(), ) if result.returncode == 0 and result.stdout.strip(): user = result.stdout.strip() diff --git a/iris/platform/identity.py b/iris/platform/identity.py index 530ea17..0d2ede8 100644 --- a/iris/platform/identity.py +++ b/iris/platform/identity.py @@ -14,6 +14,8 @@ import re import subprocess + +from iris.shell import git_env from datetime import datetime, timedelta, timezone from iris.models.commit import Commit @@ -33,6 +35,7 @@ def _resolve_gh_name(username: str) -> str | None: r = subprocess.run( ["gh", "api", f"users/{username}", "-q", ".name"], capture_output=True, text=True, timeout=5, + env=git_env(), ) if r.returncode == 0 and r.stdout.strip(): return r.stdout.strip() @@ -66,6 +69,7 @@ def _resolve_authors_via_commit_list(nwo: str, days: int) -> dict[str, str]: '"\\(.commit.author.email)\\t\\(.author.login)"', ], capture_output=True, text=True, timeout=30, + env=git_env(), ) except (FileNotFoundError, subprocess.TimeoutExpired): return {} @@ -90,6 +94,7 @@ def _resolve_emails_via_repo(nwo: str, emails: set[str]) -> dict[str, str]: ["gh", "api", f"repos/{nwo}/commits?author={email}&per_page=1", "-q", ".[0].author.login"], capture_output=True, text=True, timeout=10, + env=git_env(), ) if r.returncode == 0 and r.stdout.strip(): result[email] = r.stdout.strip() diff --git a/iris/shell.py b/iris/shell.py new file mode 100644 index 0000000..bbbbb59 --- /dev/null +++ b/iris/shell.py @@ -0,0 +1,26 @@ +"""Environment for the `git` and `gh` subprocesses the engine shells out to. + +Git localizes its messages through `LANG`/`LC_MESSAGES`. On a `pt_BR.UTF-8` +machine, `git -C /missing log` fails with "Arquivo ou diretório inexistente"; +on the CI runner it fails with "No such file or directory". Everything the +engine surfaces or matches from git's text — error messages in `RuntimeError`, +tests asserting on them — therefore depended on the machine's locale. + +`git_env()` pins the C locale for those subprocesses only, without touching +the user's shell. Structural output (`--porcelain`, `--numstat`, `--pretty` +with separators, `--json`) was already locale-independent; this makes the +human-readable remainder deterministic too. + +It is a function, not a module-level constant, so it reflects `os.environ` +at call time (tests monkeypatch it) instead of a snapshot taken at import. +""" + +import os + + +def git_env() -> dict[str, str]: + """Return the current environment with the locale pinned to C.""" + env = dict(os.environ) + env["LC_ALL"] = "C" + env["LANG"] = "C" + return env diff --git a/tests/test_subprocess_locale.py b/tests/test_subprocess_locale.py new file mode 100644 index 0000000..25a00ee --- /dev/null +++ b/tests/test_subprocess_locale.py @@ -0,0 +1,73 @@ +"""Locale-independence of the git/gh subprocesses. + +Two guards: the runtime one proves git's error text reaches the caller in +English even when the process runs under a Portuguese locale; the static one +keeps every future `git`/`gh` `subprocess.run` on the pinned environment. + +Runnable as a plain script: `python tests/test_subprocess_locale.py`. +""" + +import os +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from iris.ingestion.git_reader import read_commits +from iris.shell import git_env + +_ROOT = Path(__file__).resolve().parent.parent / "iris" + +# A `subprocess.run(` whose argv literal starts with "git" or "gh". User-supplied +# commands (external_reader) and non-git tools (curl in the self-updater) are +# deliberately outside this rule. +_GIT_CALL = re.compile( + r"subprocess\.run\((?P.*?)\n\s*\)", re.DOTALL, +) +_STARTS_WITH_GIT = re.compile(r"""\[\s*["'](?:git|gh)["']""") + + +def test_git_env_pins_c_locale_and_keeps_the_rest(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LANG", "pt_BR.UTF-8") + monkeypatch.setenv("LC_ALL", "pt_BR.UTF-8") + monkeypatch.setenv("IRIS_PROBE", "kept") + env = git_env() + assert env["LC_ALL"] == "C" + assert env["LANG"] == "C" + assert env["IRIS_PROBE"] == "kept" + # Never mutates the caller's environment. + assert os.environ["LC_ALL"] == "pt_BR.UTF-8" + + +def test_git_error_text_is_english_under_portuguese_locale( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + # Regression for the case that failed on pt_BR machines while passing in + # CI: the message git prints for a missing path. + monkeypatch.setenv("LANG", "pt_BR.UTF-8") + monkeypatch.setenv("LC_ALL", "pt_BR.UTF-8") + monkeypatch.setenv("LC_MESSAGES", "pt_BR.UTF-8") + with pytest.raises(RuntimeError) as exc: + read_commits(str(tmp_path / "does-not-exist"), days=90) + assert "no such file or directory" in str(exc.value).lower() + + +def test_every_git_or_gh_subprocess_passes_the_pinned_env() -> None: + offenders: list[str] = [] + for path in sorted(_ROOT.rglob("*.py")): + text = path.read_text() + for m in _GIT_CALL.finditer(text): + body = m.group("body") + if not _STARTS_WITH_GIT.search(body): + continue + if "env=" not in body: + line = text.count("\n", 0, m.start()) + 1 + offenders.append(f"{path.relative_to(_ROOT.parent)}:{line}") + assert not offenders, "git/gh subprocess.run without env=git_env(): " + ", ".join(offenders) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"]))