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

60 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-18 08:48 +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 

11 

12from .models import GitAuthor, GitMetadata 

13 

14logger = logging.getLogger("somesy") 

15 

16 

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

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

19 git = shutil.which("git") 

20 if git is None: 

21 raise RuntimeError("Git executable not found") 

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

23 [git, *args], 

24 cwd=path, 

25 capture_output=True, 

26 check=True, 

27 text=True, 

28 ) 

29 return result.stdout.strip() 

30 

31 

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

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

34 try: 

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

36 except (subprocess.CalledProcessError, FileNotFoundError): 

37 try: 

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

39 return _git(path, "remote", "get-url", remotes[0]) if remotes else None 

40 except (subprocess.CalledProcessError, FileNotFoundError): 

41 return None 

42 

43 

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

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

46 try: 

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

48 except (subprocess.CalledProcessError, FileNotFoundError): 

49 return [] 

50 

51 identities = [] 

52 for record in raw.splitlines(): 

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

54 if name: 

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

56 

57 counts = Counter(identities) 

58 return [ 

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

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

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

62 ) 

63 ] 

64 

65 

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

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

68 try: 

69 value = _git(path, *args) 

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

71 except (subprocess.CalledProcessError, FileNotFoundError, ValueError): 

72 return None 

73 

74 

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

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

77 try: 

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

79 except (subprocess.CalledProcessError, FileNotFoundError): 

80 return False 

81 

82 

83def harvest(path: Path = Path.cwd()) -> GitMetadata | None: 

84 """Harvest Somesy-relevant metadata from ``path`` if it is a Git repository.""" 

85 try: 

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

87 except (subprocess.CalledProcessError, FileNotFoundError): 

88 return None 

89 

90 try: 

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

92 except (subprocess.CalledProcessError, FileNotFoundError): 

93 version = None 

94 

95 shallow = _is_shallow(root) 

96 if shallow: 

97 logger.warning( 

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

99 ) 

100 

101 return GitMetadata( 

102 name=root.name, 

103 repository=_remote(root), 

104 version=version, 

105 date_created=None 

106 if shallow 

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

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

109 authors=_authors(root), 

110 )