Coverage for src/somesy/git/harvest.py: 87%

70 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-11-03 13:13 +0000

1"""Harvest project metadata from a Git repository.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import shutil 

7import subprocess 

8from collections import Counter 

9from datetime import date 

10from pathlib import Path 

11from urllib.parse import urlsplit, urlunsplit 

12 

13from .models import GitAuthor, GitMetadata 

14 

15logger = logging.getLogger("somesy") 

16 

17_GIT_ERRORS = (subprocess.CalledProcessError, FileNotFoundError) 

18 

19 

20def _git(path: Path, *args: str) -> str: 

21 """Run Git without invoking a shell.""" 

22 git = shutil.which("git") 

23 if git is None: 

24 raise FileNotFoundError("Git executable not found") 

25 result = subprocess.run( # noqa: S603 - arguments are fixed internal Git commands 

26 [git, *args], 

27 cwd=path, 

28 capture_output=True, 

29 check=True, 

30 encoding="utf-8", 

31 errors="replace", 

32 ) 

33 return result.stdout.strip() 

34 

35 

36def _strip_credentials(url: str) -> str: 

37 """Remove userinfo (user:token) from http(s) URLs; leave other forms as is.""" 

38 parts = urlsplit(url) 

39 if parts.scheme not in ("http", "https") or "@" not in parts.netloc: 

40 return url 

41 return urlunsplit(parts._replace(netloc=parts.netloc.rpartition("@")[2])) 

42 

43 

44def _remote(path: Path) -> str | None: 

45 """Return the origin URL, or the first configured remote URL.""" 

46 try: 

47 return _strip_credentials(_git(path, "remote", "get-url", "origin")) 

48 except _GIT_ERRORS: 

49 try: 

50 remotes = _git(path, "remote").splitlines() 

51 if not remotes: 

52 return None 

53 return _strip_credentials(_git(path, "remote", "get-url", remotes[0])) 

54 except _GIT_ERRORS: 

55 return None 

56 

57 

58def _authors(path: Path) -> list[GitAuthor]: 

59 """Return all distinct mailmap-aware Git authors, ranked by commit count.""" 

60 try: 

61 raw = _git(path, "log", "--all", "--format=%aN%x00%aE") 

62 except _GIT_ERRORS: 

63 return [] 

64 

65 identities = [] 

66 for record in raw.splitlines(): 

67 name, _, email = record.partition("\x00") 

68 if name: 

69 identities.append((name, email or None)) 

70 

71 counts = Counter(identities) 

72 return [ 

73 GitAuthor(name=name, email=email, commit_count=count) 

74 for (name, email), count in sorted( 

75 counts.items(), key=lambda item: (-item[1], item[0]) 

76 ) 

77 ] 

78 

79 

80def _date(path: Path, *args: str) -> date | None: 

81 """Return a Git date, if the requested revision exists.""" 

82 try: 

83 value = _git(path, *args) 

84 return date.fromisoformat(value.splitlines()[0]) if value else None 

85 except (*_GIT_ERRORS, ValueError): 

86 return None 

87 

88 

89def _is_shallow(path: Path) -> bool: 

90 """Return whether Git history is incomplete.""" 

91 try: 

92 return _git(path, "rev-parse", "--is-shallow-repository") == "true" 

93 except _GIT_ERRORS: 

94 return False 

95 

96 

97def harvest(path: Path | None = None) -> GitMetadata | None: 

98 """Harvest Somesy-relevant metadata from ``path`` (default: cwd) if it is a Git repository.""" 

99 path = Path.cwd() if path is None else path 

100 try: 

101 root = Path(_git(path, "rev-parse", "--show-toplevel")) 

102 except _GIT_ERRORS: 

103 return None 

104 

105 try: 

106 version = _git(root, "describe", "--tags", "--abbrev=0") or None 

107 except _GIT_ERRORS: 

108 version = None 

109 

110 shallow = _is_shallow(root) 

111 if shallow: 

112 logger.warning( 

113 "Git history is shallow; omitting dateCreated. Fetch full history to enrich it." 

114 ) 

115 

116 return GitMetadata( 

117 name=root.name, 

118 repository=_remote(root), 

119 version=version, 

120 date_created=None 

121 if shallow 

122 else _date(root, "log", "--reverse", "--format=%cs"), 

123 date_modified=_date(root, "log", "-1", "--format=%cs"), 

124 authors=_authors(root), 

125 )