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