lpip.gitutils

src/lpip/gitutils.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
"""Locate and invoke the git executable (ADR-0008). No Python Git library."""

from __future__ import annotations

import os
import shutil
import subprocess
from collections.abc import Mapping, Sequence
from pathlib import Path

from lpip.errors import GitCommandError, GitNotFoundError
from lpip.validation import to_git_remote_url

_WIN_FALLBACK_RELATIVE = Path("Git") / "cmd" / "git.exe"
_WIN_USER_RELATIVE = Path("Programs") / "Git" / "cmd" / "git.exe"


def find_git_executable(
    *,
    environ: Mapping[str, str] | None = None,
    search_windows_fallbacks: bool | None = None,
) -> Path | None:
    """PATH first, then typical Git for Windows locations (ADR-0008)."""
    env = environ if environ is not None else os.environ
    path_var = env.get("PATH")
    found = shutil.which("git", path=path_var)
    if found:
        return Path(found)
    use_windows = os.name == "nt" if search_windows_fallbacks is None else search_windows_fallbacks
    if not use_windows:
        return None
    candidates: list[Path] = []
    for key in ("PROGRAMFILES", "PROGRAMFILES(X86)"):
        root = env.get(key)
        if root:
            candidates.append(Path(root) / _WIN_FALLBACK_RELATIVE)
    local = env.get("LOCALAPPDATA")
    if local:
        candidates.append(Path(local) / _WIN_USER_RELATIVE)
    for candidate in candidates:
        if candidate.is_file():
            return candidate
    return None


def require_git(
    *,
    environ: Mapping[str, str] | None = None,
    search_windows_fallbacks: bool | None = None,
) -> Path:
    found = find_git_executable(
        environ=environ,
        search_windows_fallbacks=search_windows_fallbacks,
    )
    if found is None:
        raise GitNotFoundError()
    return found


def run_git(
    args: Sequence[str],
    *,
    cwd: Path | None = None,
    environ: Mapping[str, str] | None = None,
    git: Path | None = None,
) -> subprocess.CompletedProcess[str]:
    """Run git. Inherits the environment; does not set GIT_TERMINAL_PROMPT=0."""
    exe = git or require_git(environ=environ)
    env = dict(environ) if environ is not None else None
    completed = subprocess.run(  # nosec B603
        [str(exe), *args],
        cwd=cwd,
        env=env,
        text=True,
        capture_output=True,
        check=False,
    )
    if completed.returncode != 0:
        detail = (completed.stderr or completed.stdout or "").strip()
        raise GitCommandError(list(args), detail=detail or None)
    return completed


def git_ls_remote_tags(url: str, *, environ: Mapping[str, str] | None = None) -> tuple[str, ...]:
    """Tag names from ``git ls-remote --tags`` (ADR-0016). Drops peeled ``^{}`` lines."""
    completed = run_git(
        ["ls-remote", "--tags", "--", to_git_remote_url(url)],
        environ=environ,
    )
    tags: list[str] = []
    for line in completed.stdout.splitlines():
        parts = line.split()
        if len(parts) < 2:
            continue
        ref = parts[1]
        prefix = "refs/tags/"
        if not ref.startswith(prefix):
            continue
        name = ref[len(prefix) :]
        if name.endswith("^{}"):
            continue
        tags.append(name)
    return tuple(tags)


def git_clone(url: str, dest: Path, *, environ: Mapping[str, str] | None = None) -> None:
    dest.parent.mkdir(parents=True, exist_ok=True)
    run_git(["clone", "--", to_git_remote_url(url), str(dest)], environ=environ)


def git_fetch(repo: Path, *, environ: Mapping[str, str] | None = None) -> None:
    run_git(["fetch", "--all", "--tags"], cwd=repo, environ=environ)


def git_checkout(repo: Path, revision: str, *, environ: Mapping[str, str] | None = None) -> None:
    run_git(["checkout", revision], cwd=repo, environ=environ)


def git_current_revision(repo: Path, *, environ: Mapping[str, str] | None = None) -> str:
    return run_git(["rev-parse", "HEAD"], cwd=repo, environ=environ).stdout.strip()