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

43 statements  

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

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

2 

3from __future__ import annotations 

4 

5import shutil 

6import subprocess 

7from collections import Counter 

8from pathlib import Path 

9 

10from .models import GitAuthor, GitMetadata 

11 

12 

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

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

15 git = shutil.which("git") 

16 if git is None: 

17 raise RuntimeError("Git executable not found") 

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

19 [git, *args], 

20 cwd=path, 

21 capture_output=True, 

22 check=True, 

23 text=True, 

24 ) 

25 return result.stdout.strip() 

26 

27 

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

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

30 try: 

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

32 except (subprocess.CalledProcessError, FileNotFoundError): 

33 try: 

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

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

36 except (subprocess.CalledProcessError, FileNotFoundError): 

37 return None 

38 

39 

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

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

42 try: 

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

44 except (subprocess.CalledProcessError, FileNotFoundError): 

45 return [] 

46 

47 identities = [] 

48 for record in raw.splitlines(): 

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

50 if name: 

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

52 

53 counts = Counter(identities) 

54 return [ 

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

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

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

58 ) 

59 ] 

60 

61 

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

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

64 try: 

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

66 except (subprocess.CalledProcessError, FileNotFoundError): 

67 return None 

68 

69 try: 

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

71 except (subprocess.CalledProcessError, FileNotFoundError): 

72 version = None 

73 

74 return GitMetadata( 

75 name=root.name, 

76 repository=_remote(root), 

77 version=version, 

78 authors=_authors(root), 

79 )