ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
work_git.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Process choke point, token redaction, and the git adapter behind ``work``.
4
5Every subprocess this tool starts goes through :func:`run_process`, and every
6byte handed back from one has already passed :func:`redact`. That is
7deliberate. A GitHub token reaches a repository tool through a remote URL, an
8askpass helper, or a ``gh`` diagnostic far more often than through anything the
9tool itself holds, and a single choke point is the only arrangement in which
10"is this output redacted" has one answer rather than one answer per call site.
11
12The git surface is split in two on purpose. :func:`run_git_readonly` refuses
13any argv whose subcommand is outside :data:`READ_ONLY_SUBCOMMANDS`, so a path
14documented as read-only -- ``work landed`` is the main reason the guard exists
15-- cannot quietly acquire a ``push`` or a ``branch -D`` in a later edit.
16The unguarded process adapter is private to that wrapper; workspace creation
17belongs exclusively to ``scripts/dev/agent_workspace.sh``.
18
19Nothing in this module fetches, pushes, or otherwise reaches the network, and
20nothing here deletes a file, a branch, or a worktree.
21
22Failure is signalled by raising :class:`WorkError` or one of its subclasses;
23the exit-code mapping belongs to ``work.py``.
24"""
25
26from __future__ import annotations
27
28import re
29import subprocess
30import sys
31from collections.abc import Mapping, Sequence
32from dataclasses import dataclass
33from pathlib import Path
34
35sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "scripts/dev"))
36
37from git_environment import (
38 GitEnvironmentError,
39 reject_untrusted_executable_attributes,
40 sanitized_git_environment,
41 trusted_git_executable,
42)
43
44#: Wall-clock ceiling for any single captured command, in seconds.
45DEFAULT_TIMEOUT_S = 60
46
47#: Replacement text substituted for anything token-shaped.
48REDACTED = "[REDACTED-TOKEN]"
49
50#: Bounds of the printable ASCII range :func:`printable` keeps, and what it
51#: substitutes for everything else, including line terminators.
52PRINTABLE_LOW = 0x20
53PRINTABLE_HIGH = 0x7E
54UNPRINTABLE = "?"
55
56#: Shapes GitHub currently issues: the ``gh?_`` family and fine-grained PATs.
57TOKEN_PATTERNS = (
58 re.compile(r"gh[pousr]_[A-Za-z0-9]{16,}"),
59 re.compile(r"github_pat_[A-Za-z0-9_]{20,}"),
60)
61
62#: Per-subcommand allowlist of the EXACT option spellings this tool uses. The
63#: guard is a whitelist rather than a blacklist because "read-only subcommand"
64#: is not a property of the subcommand at all: ``git diff --output=FILE`` and
65#: ``git log --output=FILE`` both truncate and create files, and both sailed
66#: through a guard that only looked at the first word. Any token starting with
67#: ``-`` that is not listed here for its own subcommand is refused.
68READ_ONLY_OPTIONS: dict[str, frozenset[str]] = {
69 "rev-parse": frozenset(
70 {"--show-toplevel", "--git-common-dir", "--git-dir", "--verify", "--quiet"}
71 ),
72 "status": frozenset({"--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"}),
73 "diff": frozenset({"--stat", "--no-ext-diff", "--no-textconv"}),
74 "log": frozenset(),
75 "show-ref": frozenset({"--verify", "--quiet"}),
76 "worktree": frozenset({"--porcelain"}),
77 "branch": frozenset({"--list"}),
78}
79
80#: git subcommands that cannot change repository state. Derived from the
81#: allowlist above so the two can never disagree about what is permitted.
82READ_ONLY_SUBCOMMANDS = frozenset(READ_ONLY_OPTIONS)
83
84#: Subcommands whose first positional argument selects a mode, and the only
85#: mode each may select. ``git worktree list`` reads; ``git worktree add`` and
86#: ``git worktree remove`` very much do not.
87READ_ONLY_MODES: dict[str, str] = {"worktree": "list"}
88
89#: Options a subcommand must carry to be the reading form of itself.
90REQUIRED_OPTIONS: dict[str, str] = {"branch": "--list"}
91
92#: Fixed Git prefix applied by this module after the caller argv has passed the
93#: read-only guard. Repository configuration is deliberately overridden after
94#: it is loaded: a local ``core.fsmonitor`` helper is executable code, while a
95#: pager, external diff, or untracked-cache override can hide the truth the
96#: workflow client is asking Git to report.
97GIT_READ_PREFIX = (
98 "--no-pager",
99 "--no-optional-locks",
100 "-c",
101 "core.fsmonitor=false",
102 "-c",
103 "core.untrackedCache=false",
104 "-c",
105 "core.pager=cat",
106 "-c",
107 "pager.status=false",
108 "-c",
109 "pager.diff=false",
110 "-c",
111 "diff.external=",
112)
113
114
115class WorkError(Exception):
116 """Any condition that stops ``work`` from producing a trustworthy answer."""
117
118
119class ToolMissingError(WorkError):
120 """A required external executable is absent from ``PATH``."""
121
122
123class GitCommandError(WorkError):
124 """A git command ran and reported a non-zero status."""
125
126
127class GitWriteAttemptError(WorkError):
128 """An argv that could change repository state reached a read-only runner."""
129
130
131def redact(text: str) -> str:
132 """Replace every token-shaped substring in ``text`` with a fixed placeholder.
133
134 Args:
135 text: Arbitrary captured output or exception text.
136
137 Returns:
138 The same text with each GitHub-token-shaped run replaced by
139 :data:`REDACTED`.
140 """
141 out = text
142 for pattern in TOKEN_PATTERNS:
143 out = pattern.sub(REDACTED, out)
144 return out
145
146
147def printable(text: str) -> str:
148 """Reduce ``text`` to printable ASCII, so nothing it contains can rewrite a report.
149
150 A manifest is a file on disk that anyone able to write the state directory
151 can author, and several of its fields are pure display data that no
152 validation constrains -- ``base_ref`` and ``creator`` most obviously. An
153 escape sequence in one of those repositions the cursor and repaints the
154 line, so a planted record could make ``work landed`` report a base, a
155 branch or an author that is not what it actually found.
156
157 Report callers pass one logical line at a time. Newlines and every other
158 character outside space..tilde therefore become :data:`UNPRINTABLE`; a
159 planted metadata field cannot inject a second, trusted-looking row.
160
161 Args:
162 text: Any string on its way to stdout or stderr.
163
164 Returns:
165 The same text with every non-printable character replaced.
166 """
167 return "".join(
168 char if PRINTABLE_LOW <= ord(char) <= PRINTABLE_HIGH else UNPRINTABLE for char in text
169 )
170
171
172@dataclass(frozen=True)
173class Completed:
174 """One captured subprocess result, already redacted."""
175
176 argv: tuple[str, ...]
177 returncode: int
178 stdout: str
179 stderr: str
180
181 @property
182 def ok(self) -> bool:
183 """Whether the process exited zero."""
184 return self.returncode == 0
185
186
187def run_process(
188 argv: Sequence[str],
189 *,
190 cwd: Path | None = None,
191 timeout: int = DEFAULT_TIMEOUT_S,
192 env: Mapping[str, str] | None = None,
193) -> Completed:
194 """Run ``argv`` with output captured, and return the redacted result.
195
196 Args:
197 argv: Full argument vector, executable first. Never a shell string.
198 cwd: Directory to run from, or None to inherit the caller's.
199 timeout: Seconds before the child is killed and the call fails.
200 env: Explicit child environment, or None to inherit the caller's.
201
202 Returns:
203 A :class:`Completed` whose streams have passed :func:`redact`.
204
205 Raises:
206 ToolMissingError: The executable named by ``argv[0]`` does not exist.
207 WorkError: The command exceeded ``timeout``.
208 """
209 listed = list(argv)
210 try:
211 proc = subprocess.run( # noqa: S603 -- fixed argv list, no shell, resolved executable
212 listed,
213 cwd=None if cwd is None else str(cwd),
214 capture_output=True,
215 text=True,
216 timeout=timeout,
217 env=None if env is None else dict(env),
218 check=False,
219 )
220 except FileNotFoundError as exc:
221 msg = f"executable not found: {redact(str(exc))}"
222 raise ToolMissingError(msg) from exc
223 except subprocess.TimeoutExpired as exc:
224 msg = f"command timed out after {timeout}s: {redact(' '.join(listed))}"
225 raise WorkError(msg) from exc
226 return Completed(
227 argv=tuple(listed),
228 returncode=proc.returncode,
229 stdout=redact(proc.stdout or ""),
230 stderr=redact(proc.stderr or ""),
231 )
232
233
234def git_executable() -> str:
235 """Return the repository's one absolute control-plane Git authority."""
236 try:
237 return trusted_git_executable()
238 except GitEnvironmentError as exc:
239 raise ToolMissingError(str(exc)) from exc
240
241
242def git_child_environment() -> dict[str, str]:
243 """Return the shared hardened environment used by every child Git.
244
245 Returns:
246 The environment produced by the repository-wide nested-Git authority.
247 """
248 return sanitized_git_environment()
249
250
251def _subcommand_index(argv: Sequence[str]) -> int:
252 """Return the index of the git subcommand in ``argv``, or -1 if there is none.
253
254 The subcommand must be argv[0]. No global option, including ``-C``, is
255 skipped: a leading option remains the token :func:`assert_read_only` judges
256 and is refused rather than silently reinterpreted.
257
258 Args:
259 argv: A git argument vector with the executable already removed.
260
261 Returns:
262 Index of the subcommand token, or -1 when the vector has none.
263 """
264 return 0 if argv else -1
265
266
267def git_subcommand(argv: Sequence[str]) -> str | None:
268 """Return argv[0] as the Git subcommand; never skip global options.
269
270 Args:
271 argv: A git argument vector with the executable already removed.
272
273 Returns:
274 The subcommand token, or None when the vector names none.
275 """
276 index = _subcommand_index(argv)
277 return None if index < 0 else argv[index]
278
279
280def _assert_subcommand(argv: Sequence[str]) -> int:
281 """Check everything before the subcommand, and return where it starts.
282
283 Args:
284 argv: A git argument vector with the executable already removed.
285
286 Returns:
287 The index of the subcommand token.
288
289 Raises:
290 GitWriteAttemptError: The vector carries a global option other than the
291 ``-C <path>`` pair, names no subcommand, or names one outside
292 :data:`READ_ONLY_SUBCOMMANDS`.
293 """
294 index = _subcommand_index(argv)
295 if index < 0:
296 msg = "read-only git runner received an argv with no subcommand"
297 raise GitWriteAttemptError(msg)
298 sub = argv[index]
299 if sub.startswith("-"):
300 msg = f"read-only git runner refused the global option: {sub}"
301 raise GitWriteAttemptError(msg)
302 if sub not in READ_ONLY_SUBCOMMANDS:
303 msg = f"read-only git runner refused subcommand: {sub}"
304 raise GitWriteAttemptError(msg)
305 return index
306
307
308def _assert_tail(sub: str, tail: Sequence[str]) -> None:
309 """Check every argument after the subcommand against that subcommand's allowlist.
310
311 Args:
312 sub: The subcommand, already known to be permitted.
313 tail: Everything after it.
314
315 Raises:
316 GitWriteAttemptError: An option is not allowlisted for ``sub``, a
317 required option is absent, or a mode-selecting positional is either
318 wrong or duplicated.
319 """
320 allowed = READ_ONLY_OPTIONS[sub]
321 for token in tail:
322 if token.startswith("-") and token not in allowed:
323 msg = f"read-only git runner refused option {token!r} for subcommand {sub!r}"
324 raise GitWriteAttemptError(msg)
325 required = REQUIRED_OPTIONS.get(sub)
326 if required is not None and required not in tail:
327 msg = f"read-only git runner requires {required} for subcommand {sub!r}"
328 raise GitWriteAttemptError(msg)
329 mode = READ_ONLY_MODES.get(sub)
330 if mode is None:
331 return
332 positionals = [token for token in tail if not token.startswith("-")]
333 if positionals != [mode]:
334 msg = f"read-only git runner allows only: git {sub} {mode}"
335 raise GitWriteAttemptError(msg)
336
337
338def assert_read_only(argv: Sequence[str]) -> None:
339 """Raise unless ``argv`` is a git invocation that cannot change any state.
340
341 The check is a whitelist at three levels -- the global prefix, the
342 subcommand, and every single option after it -- because none of the three
343 is safe on its own. ``git diff --output=FILE`` truncates a file while being
344 a "read-only subcommand", and ``git worktree list add -b x /tmp/z HEAD``
345 is a write wearing a listing subcommand as a hat.
346
347 Args:
348 argv: A git argument vector with the executable already removed.
349
350 Raises:
351 GitWriteAttemptError: The vector is not one of the exact forms this
352 tool issues.
353 """
354 index = _assert_subcommand(argv)
355 _assert_tail(argv[index], list(argv[index + 1 :]))
356
357
358def _run_git(argv: Sequence[str], *, cwd: Path, timeout: int = DEFAULT_TIMEOUT_S) -> Completed:
359 """Run git with ``argv`` from ``cwd`` with no read-only guard applied.
360
361 Args:
362 argv: Git arguments with the executable omitted.
363 cwd: Directory to run from.
364 timeout: Seconds before the child is killed.
365
366 Returns:
367 The captured, redacted result.
368 """
369 return run_process(
370 [git_executable(), *GIT_READ_PREFIX, *argv],
371 cwd=cwd,
372 timeout=timeout,
373 env=git_child_environment(),
374 )
375
376
377def run_git_readonly(
378 argv: Sequence[str], *, cwd: Path, timeout: int = DEFAULT_TIMEOUT_S
379) -> Completed:
380 """Run a git command that has been proved incapable of changing state.
381
382 Args:
383 argv: Git arguments with the executable omitted.
384 cwd: Directory to run from.
385 timeout: Seconds before the child is killed.
386
387 Returns:
388 The captured, redacted result.
389
390 Raises:
391 GitWriteAttemptError: ``argv`` did not pass :func:`assert_read_only`.
392 """
393 assert_read_only(argv)
394 return _run_git(argv, cwd=cwd, timeout=timeout)
395
396
397def git_text(argv: Sequence[str], *, cwd: Path) -> str:
398 """Run a read-only git command and return its stdout, failing loudly.
399
400 Args:
401 argv: Git arguments with the executable omitted.
402 cwd: Directory to run from.
403
404 Returns:
405 Captured stdout.
406
407 Raises:
408 GitCommandError: The command exited non-zero.
409 """
410 done = run_git_readonly(argv, cwd=cwd)
411 if not done.ok:
412 joined = " ".join(argv)
413 msg = f"git {joined} failed (exit {done.returncode}): {done.stderr.strip()}"
414 raise GitCommandError(msg)
415 return done.stdout
416
417
418@dataclass(frozen=True)
419class RepoPaths:
420 """Where the repository under the caller's feet actually lives."""
421
422 toplevel: Path
423 common_dir: Path
424 git_dir: Path
425
426 @property
427 def is_linked_worktree(self) -> bool:
428 """Whether this checkout is a linked worktree rather than the main one."""
429 return self.git_dir != self.common_dir
430
431
432def discover_repo(cwd: Path) -> RepoPaths:
433 """Locate the repository containing ``cwd``.
434
435 ``--git-common-dir`` is asked for rather than assuming ``.git`` is a
436 directory: in a linked worktree ``.git`` is a file, and the shared state
437 this tool writes must land beside the main repository rather than once per
438 worktree.
439
440 Args:
441 cwd: Any directory inside the repository.
442
443 Returns:
444 The resolved toplevel, common git directory, and per-worktree git
445 directory.
446
447 Raises:
448 GitCommandError: ``cwd`` is not inside a git repository.
449 """
450 toplevel = Path(git_text(["rev-parse", "--show-toplevel"], cwd=cwd).strip()).resolve()
451 common = _resolve_git_path(git_text(["rev-parse", "--git-common-dir"], cwd=cwd).strip(), cwd)
452 git_dir = _resolve_git_path(git_text(["rev-parse", "--git-dir"], cwd=cwd).strip(), cwd)
453 return RepoPaths(toplevel=toplevel, common_dir=common, git_dir=git_dir)
454
455
456def reject_executable_attributes(cwd: Path, commit: str | None = None) -> None:
457 """Apply the repository-wide trusted attribute policy as a workflow error."""
458 try:
459 reject_untrusted_executable_attributes(cwd, commit)
460 except GitEnvironmentError as exc:
461 raise WorkError(str(exc)) from exc
462
463
464def _resolve_git_path(raw: str, cwd: Path) -> Path:
465 """Turn a possibly relative ``git rev-parse`` path answer into an absolute one.
466
467 Args:
468 raw: The path git printed.
469 cwd: The directory the command ran from, which relative answers are
470 relative to.
471
472 Returns:
473 An absolute, symlink-resolved path.
474 """
475 path = Path(raw)
476 if not path.is_absolute():
477 path = cwd / path
478 return path.resolve()
479
480
481def worktree_paths(cwd: Path) -> list[Path]:
482 """Return the resolved path of every worktree registered in this repository.
483
484 Args:
485 cwd: Any directory inside the repository.
486
487 Returns:
488 Resolved worktree paths, in the order git reported them.
489 """
490 out = git_text(["worktree", "list", "--porcelain"], cwd=cwd)
491 marker = "worktree "
492 return [
493 Path(line[len(marker) :]).resolve() for line in out.splitlines() if line.startswith(marker)
494 ]
495
496
497def branch_exists(name: str, *, cwd: Path) -> bool:
498 """Whether a local branch of exactly ``name`` exists.
499
500 Args:
501 name: Branch name without the ``refs/heads/`` prefix.
502 cwd: Any directory inside the repository.
503
504 Returns:
505 True when the ref resolves.
506 """
507 done = run_git_readonly(["show-ref", "--verify", "--quiet", f"refs/heads/{name}"], cwd=cwd)
508 return done.ok
509
510
511def resolve_commit(ref: str, *, cwd: Path) -> str | None:
512 """Resolve ``ref`` to a commit id without touching the network.
513
514 Args:
515 ref: Any revision expression.
516 cwd: Any directory inside the repository.
517
518 Returns:
519 The full commit id, or None when the reference does not resolve.
520 """
521 done = run_git_readonly(["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=cwd)
522 text = done.stdout.strip()
523 return text if done.ok and text else None
524
525
526def resolve_tree(ref: str, *, cwd: Path) -> str | None:
527 """Resolve the tree object belonging to ``ref`` without touching the network.
528
529 Args:
530 ref: Commit-ish whose content tree is required.
531 cwd: Any directory inside the repository.
532
533 Returns:
534 The full tree object id, or None when the reference does not resolve.
535 """
536 done = run_git_readonly(["rev-parse", "--verify", "--quiet", f"{ref}^{{tree}}"], cwd=cwd)
537 text = done.stdout.strip()
538 return text if done.ok and text else None
539
540
541def porcelain_status(cwd: Path) -> list[str]:
542 """Return the ``git status --porcelain`` lines for the tree at ``cwd``.
543
544 Args:
545 cwd: A working tree.
546
547 Returns:
548 One entry per reported path, untracked files included.
549 """
550 reject_executable_attributes(cwd)
551 out = git_text(
552 ["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"],
553 cwd=cwd,
554 )
555 return [line for line in out.splitlines() if line.strip()]
556
557
558def diff_stat(cwd: Path, base: str) -> str:
559 """Return ``git diff --stat <base>...HEAD`` for the tree at ``cwd``.
560
561 Args:
562 cwd: A working tree.
563 base: The base revision to compare against.
564
565 Returns:
566 The diffstat text, or an explanatory line when the base does not
567 resolve in that tree.
568 """
569 reject_executable_attributes(cwd)
570 done = run_git_readonly(
571 ["diff", "--no-ext-diff", "--no-textconv", "--stat", f"{base}...HEAD"],
572 cwd=cwd,
573 )
574 if not done.ok:
575 return f"(no diffstat: base {base} did not resolve here)"
576 return done.stdout.rstrip()