Coverage for src/pullapprove/git.py: 75%
44 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-17 21:57 -0500
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-17 21:57 -0500
1from __future__ import annotations
3import os
4import subprocess
5import sys
6from collections.abc import Generator
7from pathlib import Path
10def git_pager() -> str:
11 """The pager command git would use, honoring core.pager/GIT_PAGER/PAGER.
13 Returns a shell command string (git's pager values can include arguments or
14 pipes, so callers run it through a shell, the way git does). Falls back to
15 `cat` when git is unavailable or paging is disabled.
16 """
17 try:
18 output = subprocess.check_output(["git", "var", "GIT_PAGER"], text=True).strip()
19 except (subprocess.CalledProcessError, OSError):
20 output = ""
21 return output or os.environ.get("PAGER", "") or "cat"
24def page(text: str) -> None:
25 """Display `text` through git's configured pager, the way `git diff` does."""
26 env = {**os.environ}
27 env.setdefault("LESS", "FRX") # quit if one screen, keep colors, no init clear
28 proc = subprocess.Popen(
29 git_pager(), shell=True, stdin=subprocess.PIPE, text=True, env=env
30 )
31 try:
32 proc.communicate(text)
33 except BrokenPipeError:
34 pass # the pager was closed before reading everything (e.g. `q` in less)
35 if proc.returncode:
36 sys.stdout.write(text) # the pager failed to run; don't lose the output
39def git_ls_files(path: Path) -> Generator[str]:
40 """Yield files in the git repository one at a time."""
41 process = subprocess.Popen(
42 [
43 "git",
44 "ls-files",
45 "--cached",
46 "--deleted",
47 "--others",
48 "--exclude-standard",
49 ],
50 cwd=path,
51 stdout=subprocess.PIPE,
52 text=True,
53 )
55 assert process.stdout is not None
56 for line in process.stdout:
57 yield line.strip()
59 process.stdout.close()
60 process.wait()
63def git_ls_changes(path: Path) -> Generator[str]:
64 process = subprocess.Popen(
65 [
66 "git",
67 "status",
68 "--porcelain=v1",
69 "--untracked-files=all",
70 ],
71 cwd=path,
72 stdout=subprocess.PIPE,
73 text=True,
74 )
76 assert process.stdout is not None
77 for line in process.stdout:
78 yield line.strip().split(" ", 1)[1]
80 process.stdout.close()
81 process.wait()
84def git_diff_stream(path: Path, *diff_args: str) -> Generator[str]:
85 # `-c diff.noprefix=false` because our diff parser requires the `a/`/`b/`
86 # (or mnemonic single-char) path prefixes to recognize file headers — a user
87 # with `diff.noprefix = true` in their gitconfig would otherwise produce a
88 # diff whose files we silently misattribute. An explicit `--no-prefix` in
89 # diff_args still wins (later flags override).
90 process = subprocess.Popen(
91 ["git", "-c", "diff.noprefix=false", "diff", "--no-ext-diff"] + list(diff_args),
92 cwd=path,
93 stdout=subprocess.PIPE,
94 text=True,
95 )
97 assert process.stdout is not None
98 yield from process.stdout
100 process.stdout.close()
101 returncode = process.wait()
102 # 0 = success, 1 = changes found (with --exit-code); anything else is a real
103 # git error (bad revision, unknown option) that would otherwise masquerade
104 # as an empty diff. git has already printed the reason to stderr.
105 if returncode not in (0, 1):
106 raise subprocess.CalledProcessError(returncode, ["git", "diff", *diff_args])