Coverage for src/lpip/gitutils.py: 86%
69 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 20:19 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 20:19 +0000
1"""Locate and invoke the git executable (ADR-0008). No Python Git library."""
3from __future__ import annotations
5import os
6import shutil
7import subprocess
8from collections.abc import Mapping, Sequence
9from pathlib import Path
11from lpip.errors import GitCommandError, GitNotFoundError
12from lpip.validation import to_git_remote_url
14_WIN_FALLBACK_RELATIVE = Path("Git") / "cmd" / "git.exe"
15_WIN_USER_RELATIVE = Path("Programs") / "Git" / "cmd" / "git.exe"
18def find_git_executable(
19 *,
20 environ: Mapping[str, str] | None = None,
21 search_windows_fallbacks: bool | None = None,
22) -> Path | None:
23 """PATH first, then typical Git for Windows locations (ADR-0008)."""
24 env = environ if environ is not None else os.environ
25 path_var = env.get("PATH")
26 found = shutil.which("git", path=path_var)
27 if found:
28 return Path(found)
29 use_windows = os.name == "nt" if search_windows_fallbacks is None else search_windows_fallbacks
30 if not use_windows:
31 return None
32 candidates: list[Path] = []
33 for key in ("PROGRAMFILES", "PROGRAMFILES(X86)"):
34 root = env.get(key)
35 if root: 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true
36 candidates.append(Path(root) / _WIN_FALLBACK_RELATIVE)
37 local = env.get("LOCALAPPDATA")
38 if local: 38 ↛ 40line 38 didn't jump to line 40 because the condition on line 38 was always true
39 candidates.append(Path(local) / _WIN_USER_RELATIVE)
40 for candidate in candidates: 40 ↛ 43line 40 didn't jump to line 43 because the loop on line 40 didn't complete
41 if candidate.is_file(): 41 ↛ 40line 41 didn't jump to line 40 because the condition on line 41 was always true
42 return candidate
43 return None
46def require_git(
47 *,
48 environ: Mapping[str, str] | None = None,
49 search_windows_fallbacks: bool | None = None,
50) -> Path:
51 found = find_git_executable(
52 environ=environ,
53 search_windows_fallbacks=search_windows_fallbacks,
54 )
55 if found is None:
56 raise GitNotFoundError()
57 return found
60def run_git(
61 args: Sequence[str],
62 *,
63 cwd: Path | None = None,
64 environ: Mapping[str, str] | None = None,
65 git: Path | None = None,
66) -> subprocess.CompletedProcess[str]:
67 """Run git. Inherits the environment; does not set GIT_TERMINAL_PROMPT=0."""
68 exe = git or require_git(environ=environ)
69 env = dict(environ) if environ is not None else None
70 completed = subprocess.run( # nosec B603
71 [str(exe), *args],
72 cwd=cwd,
73 env=env,
74 text=True,
75 capture_output=True,
76 check=False,
77 )
78 if completed.returncode != 0:
79 detail = (completed.stderr or completed.stdout or "").strip()
80 raise GitCommandError(list(args), detail=detail or None)
81 return completed
84def git_ls_remote_tags(url: str, *, environ: Mapping[str, str] | None = None) -> tuple[str, ...]:
85 """Tag names from ``git ls-remote --tags`` (ADR-0016). Drops peeled ``^{}`` lines."""
86 completed = run_git(
87 ["ls-remote", "--tags", "--", to_git_remote_url(url)],
88 environ=environ,
89 )
90 tags: list[str] = []
91 for line in completed.stdout.splitlines():
92 parts = line.split()
93 if len(parts) < 2: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 continue
95 ref = parts[1]
96 prefix = "refs/tags/"
97 if not ref.startswith(prefix): 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 continue
99 name = ref[len(prefix) :]
100 if name.endswith("^{}"): 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 continue
102 tags.append(name)
103 return tuple(tags)
106def git_clone(url: str, dest: Path, *, environ: Mapping[str, str] | None = None) -> None:
107 dest.parent.mkdir(parents=True, exist_ok=True)
108 run_git(["clone", "--", to_git_remote_url(url), str(dest)], environ=environ)
111def git_fetch(repo: Path, *, environ: Mapping[str, str] | None = None) -> None:
112 run_git(["fetch", "--all", "--tags"], cwd=repo, environ=environ)
115def git_checkout(repo: Path, revision: str, *, environ: Mapping[str, str] | None = None) -> None:
116 run_git(["checkout", revision], cwd=repo, environ=environ)
119def git_current_revision(repo: Path, *, environ: Mapping[str, str] | None = None) -> str:
120 return run_git(["rev-parse", "HEAD"], cwd=repo, environ=environ).stdout.strip()