Skip to content
3 changes: 2 additions & 1 deletion adopt-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -2023,7 +2023,8 @@ def cmd_check(args) -> int:
def _require_filter_repo() -> None:
if shutil.which("git-filter-repo") is None:
probe = subprocess.run(["git", "filter-repo", "--version"],
capture_output=True, text=True, check=False)
capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False)
if probe.returncode != 0:
raise Refusal(
"filter-repo-missing",
Expand Down
17 changes: 12 additions & 5 deletions scaffold-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ def pin_digest_from_gh(repository: str, commit: str) -> str:
"""
proc = subprocess.run(
["gh", "api", GH_TREE_API.format(repo=repository, commit=commit)],
capture_output=True, text=True, check=False)
# UTF-8 and STRICT, not `errors="replace"`: this JSON becomes a
# digest, and a replaced byte would be a wrong digest with a
# confident tone (#217).
capture_output=True, text=True, encoding="utf-8", check=False)
if proc.returncode != 0:
raise Refusal(
"pin-unreadable",
Expand Down Expand Up @@ -186,12 +189,14 @@ def remote_is_empty(repository: str) -> bool | None:
reliable signal — `size: 0` is also what a repository of small files says.
"""
probe = subprocess.run(["gh", "repo", "view", repository, "--json", "name"],
capture_output=True, text=True, check=False)
capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False)
if probe.returncode != 0:
return None
commits = subprocess.run(
["gh", "api", f"repos/{repository}/commits?per_page=1"],
capture_output=True, text=True, check=False)
capture_output=True, text=True, encoding="utf-8", errors="replace",
check=False)
if commits.returncode != 0:
blob = (commits.stderr + commits.stdout).lower()
if "empty" in blob:
Expand All @@ -216,7 +221,8 @@ def remote_visibility(repository: str) -> str | None:
"""
proc = subprocess.run(
["gh", "repo", "view", repository, "--json", "visibility"],
capture_output=True, text=True, check=False)
capture_output=True, text=True, encoding="utf-8", errors="replace",
check=False)
if proc.returncode != 0:
return None
try:
Expand All @@ -231,7 +237,8 @@ def local_remote_is_empty(bare: Path) -> bool | None:
if not bare.exists():
return None
out = subprocess.run(["git", "-C", str(bare), "rev-list", "--count", "--all"],
capture_output=True, text=True, check=False)
capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False)
if out.returncode != 0:
raise Refusal("remote-unreadable",
f"{bare} exists but is not a git repository")
Expand Down
23 changes: 17 additions & 6 deletions scripts/bump-leg.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ def leg_placement(submodule: Path) -> str | None:
"""
proc = subprocess.run(["git", "symbolic-ref", "--quiet", "HEAD"],
cwd=str(submodule), capture_output=True, text=True,
check=False)
encoding="utf-8", errors="replace", check=False)
return proc.stdout.strip() or None


Expand All @@ -298,7 +298,8 @@ def restore_leg(submodule: Path, placement: str | None, commit: str) -> None:
def object_present(submodule: Path, commit: str) -> bool:
proc = subprocess.run(
["git", "rev-parse", "--verify", "--quiet", f"{commit}^{{commit}}"],
cwd=str(submodule), capture_output=True, text=True, check=False)
cwd=str(submodule), capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False)
return proc.returncode == 0


Expand All @@ -307,7 +308,8 @@ def on_a_remote_branch(submodule: Path, commit: str) -> bool:
proc = subprocess.run(
["git", "for-each-ref", "--contains", commit, "--format=%(refname)",
"refs/remotes/origin/"],
cwd=str(submodule), capture_output=True, text=True, check=False)
cwd=str(submodule), capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False)
return proc.returncode == 0 and bool(proc.stdout.strip())


Expand Down Expand Up @@ -387,8 +389,15 @@ def run_validator(root: Path, script: str) -> tuple[int, str]:
"""The ROOT's own copy of a validator, its output captured for a refusal."""
if not (root / script).is_file():
return 0, f" {script} is absent; SKIPPED\n"
# `errors="replace"` and no `encoding=`: the child is a Python process,
# which writes its pipe in the locale's codec, so that codec is the one
# that reads it back (UTF-8 here would misread a Windows ANSI child).
# `replace` is for two interpreters that disagree (`-X utf8` on this one
# only): a refusal must not become a traceback on its way to printing the
# validator's words (#217).
proc = subprocess.run([sys.executable, script], cwd=str(root),
capture_output=True, text=True, check=False)
capture_output=True, text=True, errors="replace",
check=False)
return proc.returncode, (proc.stdout or "") + (proc.stderr or "")


Expand Down Expand Up @@ -430,7 +439,9 @@ def commit_once(root: Path, message: str, paths: list[str],
if not env.get(key):
env[key] = fallback
proc = subprocess.run(args, cwd=str(root), input=message,
capture_output=True, text=True, check=False, env=env)
capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False,
env=env)
if proc.returncode != 0:
raise CommandFailed(args, root, proc.returncode,
proc.stderr + proc.stdout)
Expand All @@ -440,7 +451,7 @@ def commit_once(root: Path, message: str, paths: list[str],
def current_branch(root: Path) -> str | None:
proc = subprocess.run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"],
cwd=str(root), capture_output=True, text=True,
check=False)
encoding="utf-8", errors="replace", check=False)
return proc.stdout.strip() or None


Expand Down
13 changes: 9 additions & 4 deletions scripts/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,9 @@ def _commit_one(root: Path, message: str, paths: list[str],
if not env.get(key):
env[key] = fallback
proc = subprocess.run(args, cwd=str(root), input=message,
capture_output=True, text=True, check=False, env=env)
capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False,
env=env)
if proc.returncode != 0:
raise CommandFailed(args, root, proc.returncode,
proc.stderr + proc.stdout)
Expand All @@ -354,12 +356,14 @@ def _remote_is_empty(repository: str) -> bool | None:
reliable signal — `size: 0` is also what a repository of small files says.
"""
probe = subprocess.run(["gh", "repo", "view", repository, "--json", "name"],
capture_output=True, text=True, check=False)
capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False)
if probe.returncode != 0:
return None
commits = subprocess.run(
["gh", "api", f"repos/{repository}/commits?per_page=1"],
capture_output=True, text=True, check=False)
capture_output=True, text=True, encoding="utf-8", errors="replace",
check=False)
if commits.returncode != 0:
if "empty" in (commits.stderr + commits.stdout).lower():
return True
Expand All @@ -378,7 +382,8 @@ def _local_remote_is_empty(bare: Path) -> bool | None:
if not bare.exists():
return None
out = subprocess.run(["git", "-C", str(bare), "rev-list", "--count",
"--all"], capture_output=True, text=True, check=False)
"--all"], capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False)
if out.returncode != 0:
raise Refusal("remote-unreadable",
f"{bare} exists but is not a git repository")
Expand Down
8 changes: 6 additions & 2 deletions scripts/repo_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,10 @@ def tree_digest_from_gh(repository: str, commit: str) -> str:
try:
proc = subprocess.run(
["gh", "api", GH_TREE_API.format(repo=repository, commit=commit)],
capture_output=True, text=True, check=False)
# UTF-8 and STRICT, not `errors="replace"`: this JSON becomes a
# digest, and a replaced byte would be a wrong digest with a
# confident tone (#217).
capture_output=True, text=True, encoding="utf-8", check=False)
except FileNotFoundError as exc:
raise Refusal("gh-not-found",
f"the `gh` CLI is not on PATH: {exc}") from exc
Expand Down Expand Up @@ -872,7 +875,8 @@ def free_plan_secret_hint(org: str, repo: str, what: str) -> str | None:
try:
proc = subprocess.run(
["gh", "api", f"orgs/{org}", "--jq", ".plan.name"],
capture_output=True, text=True, check=False)
capture_output=True, text=True, encoding="utf-8",
errors="replace", check=False)
except OSError: # FileNotFoundError (no `gh` on PATH) is already an OSError
return None
if proc.returncode != 0:
Expand Down
8 changes: 7 additions & 1 deletion scripts/shape_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,8 +512,14 @@ def check_program(args: list[str]) -> None:

def run(args: list[str], cwd: Path | None = None, capture: bool = True) -> str:
check_program(args)
# UTF-8, whatever the locale: git writes a path as UTF-8, and the locale's
# codec (ASCII under a POSIX `C` locale, the ANSI code page on Windows)
# raises `UnicodeDecodeError` out of `subprocess.run` on one it cannot
# read -- before `CommandFailed` exists, so the person gets a traceback
# where `REFUSED <step>` and the ruleset hint belong (#217).
proc = subprocess.run(args, cwd=str(cwd) if cwd else None,
capture_output=capture, text=True, check=False)
capture_output=capture, text=True,
encoding="utf-8", errors="replace", check=False)
if proc.returncode != 0:
raise CommandFailed(args, cwd, proc.returncode,
(proc.stderr or "") + (proc.stdout or ""))
Expand Down
2 changes: 1 addition & 1 deletion templates/family-root/scripts/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def fetch_members(root: Path) -> bool:
print(f" credential source: {source}")
sys.stdout.flush()
proc = subprocess.run(args, cwd=str(root), capture_output=True, text=True,
check=False)
encoding="utf-8", errors="replace", check=False)
if proc.returncode == 0:
print(" every member and leg fetched")
return True
Expand Down
11 changes: 7 additions & 4 deletions templates/family-root/scripts/siblings.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ def submodule_urls(root: Path) -> dict[str, str]:
if not path.is_file():
return {}
proc = subprocess.run(["git", "config", "-f", str(path), "--list"],
capture_output=True, text=True, check=False)
capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False)
if proc.returncode != 0:
return {}
paths: dict[str, str] = {}
Expand Down Expand Up @@ -299,7 +300,8 @@ def scrub(text: str, token: str) -> str:

def git_text(args: list[str], cwd: Path) -> str | None:
proc = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True,
text=True, check=False)
text=True, encoding="utf-8", errors="replace",
check=False)
return proc.stdout.strip() if proc.returncode == 0 else None


Expand Down Expand Up @@ -451,7 +453,8 @@ def place(root: Path, row: dict, urls: dict[str, str], prefix: list[str],
proc = subprocess.run(
["git", *prefix, "clone", "-q", "--recurse-submodules", "--",
url, str(target)],
capture_output=True, text=True, check=False)
capture_output=True, text=True, encoding="utf-8",
errors="replace", check=False)
if proc.returncode != 0:
sibling.state = "CLONE FAILED"
sibling.finding = (
Expand All @@ -475,7 +478,7 @@ def place(root: Path, row: dict, urls: dict[str, str], prefix: list[str],
print(f" [{project}] present at {target}; fetching only")
fetched = subprocess.run(["git", *prefix, "fetch", "--quiet", "origin"],
cwd=str(target), capture_output=True, text=True,
check=False)
encoding="utf-8", errors="replace", check=False)
# NO CHECKOUT, NO RESET, NO PULL: somebody is working in here.
sibling.state = "present, fetched" if fetched.returncode == 0 \
else "present, fetch failed"
Expand Down
Loading
Loading