Coverage for src/lpip/gitutils.py: 86%

69 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 20:20 +0000

1"""Locate and invoke the git executable (ADR-0008). No Python Git library.""" 

2 

3from __future__ import annotations 

4 

5import os 

6import shutil 

7import subprocess 

8from collections.abc import Mapping, Sequence 

9from pathlib import Path 

10 

11from lpip.errors import GitCommandError, GitNotFoundError 

12from lpip.validation import to_git_remote_url 

13 

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

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

16 

17 

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 

44 

45 

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 

58 

59 

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 

82 

83 

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) 

104 

105 

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) 

109 

110 

111def git_fetch(repo: Path, *, environ: Mapping[str, str] | None = None) -> None: 

112 run_git(["fetch", "--all", "--tags"], cwd=repo, environ=environ) 

113 

114 

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

116 run_git(["checkout", revision], cwd=repo, environ=environ) 

117 

118 

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()