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
3 changes: 3 additions & 0 deletions iris/analysis/code_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions iris/analysis/durability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions iris/analysis/priming_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

import os
import subprocess

from iris.shell import git_env
from dataclasses import dataclass
from datetime import datetime

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions iris/analysis/repo_kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

import os
import subprocess

from iris.shell import git_env
from enum import Enum


Expand Down Expand Up @@ -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 []
Expand Down
6 changes: 6 additions & 0 deletions iris/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions iris/hooks/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions iris/ingestion/diff_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import re
import subprocess

from iris.shell import git_env
from dataclasses import dataclass

from iris.models.commit import Commit
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions iris/ingestion/git_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -140,6 +144,7 @@ def read_pr_commits(
capture_output=True,
text=True,
check=True,
env=git_env(),
)
except subprocess.CalledProcessError:
return []
Expand Down
6 changes: 6 additions & 0 deletions iris/ingestion/github_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions iris/platform/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions iris/platform/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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 {}
Expand All @@ -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()
Expand Down
26 changes: 26 additions & 0 deletions iris/shell.py
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions tests/test_subprocess_locale.py
Original file line number Diff line number Diff line change
@@ -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<body>.*?)\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"]))
Loading