Coverage for src / mafw / devtools / git.py: 99%
94 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +0000
1# Copyright 2026 European Union
2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
3# SPDX-License-Identifier: EUPL-1.2
4"""
5Git operations for MAFw development tools.
7This module centralizes all functions that shell out to ``git`` commands,
8used by the release workflow, documentation builder, and dependency management.
9"""
11from __future__ import annotations
13import re
14import subprocess
15from pathlib import Path
16from typing import Any, List, Tuple
18from mafw.devtools import DevtoolsError, ensure_devtools_available
20ensure_devtools_available()
22from packaging.version import InvalidVersion, Version # noqa: E402
24from mafw.tools.shell_tools import CONSOLE, run_stdout # noqa: E402
25from mafw.tools.shell_tools import run as cmd # noqa: E402
27PYPROJECT_FILE = Path('pyproject.toml')
28"""Path to the TOML file containing the project dependencies."""
30STABLE_TAG_PATTERN = re.compile(r'^v(?P<major>\d+)\.(?P<minor>\d+)\.(?P<micro>\d+)$')
31"""Regular expression used to identify stable git tags in the form ``vX.Y.Z``."""
34def check_main_branch() -> None:
35 """Ensure the release process is executed from the ``main`` branch.
37 :raises DevtoolsError: If the current branch is not ``main``.
38 """
39 branch = run_stdout('git rev-parse --abbrev-ref HEAD')
40 if branch != 'main':
41 raise DevtoolsError('Must be on main branch.')
44def ensure_clean_git() -> None:
45 """Ensure the git working tree is clean (excluding untracked files).
47 :raises DevtoolsError: If tracked changes are present.
48 """
49 status = run_stdout('git status --porcelain -uno')
50 if status:
51 raise DevtoolsError('Git working tree is not clean. Commit or stash changes first.')
54def get_last_stable_tag() -> str | None:
55 """Return the latest stable tag matching ``vX.Y.Z``.
57 :return: Latest stable tag or ``None`` when no stable tags are present.
58 :rtype: str | None
59 """
60 versions = get_git_tags()
61 # Filter to only strict vX.Y.Z tags (no pre/post/dev — already handled by get_git_tags)
62 stable: list[tuple[Version, str]] = []
63 for v, tag in versions:
64 if STABLE_TAG_PATTERN.fullmatch(tag): 64 ↛ 63line 64 didn't jump to line 63 because the condition on line 64 was always true
65 stable.append((v, tag))
66 if not stable:
67 return None
68 return stable[-1][1]
71def prevent_duplicate_tag(version: str) -> None:
72 """Ensure the target release tag does not already exist.
74 :param version: Target release version.
75 :type version: str
76 :raises DevtoolsError: If ``v<version>`` already exists.
77 """
78 existing_tags = run_stdout('git tag').splitlines()
79 if f'v{version}' in existing_tags:
80 raise DevtoolsError(f'Tag v{version} already exists.')
83def commit_changes(version: str, dry_run: bool, *, include_changelog: bool = True) -> None:
84 """Commit tracked release artifacts for the target version.
86 :param version: Target release version.
87 :type version: str
88 :param dry_run: Whether command execution is disabled.
89 :type dry_run: bool
90 :param include_changelog: Whether ``CHANGELOG.md`` should be staged and
91 committed as part of the release artifacts.
92 :type include_changelog: bool
93 """
94 from mafw.devtools.documentation.requirements import REQUIREMENTS_GROUPS
95 from mafw.devtools.release.changelog import CHANGELOG_FILE
96 from mafw.devtools.release.versioning import ABOUT_FILE, NOTICE_FILE
98 CONSOLE.print('Committing tracked release artifacts...')
99 tracked_files: list[str] = [str(ABOUT_FILE), str(NOTICE_FILE), str(PYPROJECT_FILE), 'README.rst']
100 for group in REQUIREMENTS_GROUPS:
101 tracked_files.append(f'docs/source/requirements/{group}_requirements.rst')
103 if include_changelog:
104 tracked_files.append(str(CHANGELOG_FILE))
105 cmd(['git', 'add', *tracked_files], dry_run=dry_run)
106 cmd(['git', 'commit', '-m', f'chore(release): v{version}'], dry_run=dry_run)
109def create_tag(version: str, dry_run: bool) -> str:
110 """Create the local git tag for the target version.
112 :param version: Target release version.
113 :type version: str
114 :param dry_run: Whether command execution is disabled.
115 :type dry_run: bool
116 :return: Created tag name.
117 :rtype: str
118 """
119 tag = f'v{version}'
120 CONSOLE.print(f'Creating local tag {tag}...')
121 cmd(['git', 'tag', tag], dry_run=dry_run)
122 return tag
125def push_changes(dry_run: bool) -> None:
126 """Push commits and tags to the remote repository.
128 :param dry_run: Whether command execution is disabled.
129 :type dry_run: bool
130 """
131 CONSOLE.print('Pushing commits and tags...')
132 cmd(['git', 'push', 'origin-ssh', 'main'], dry_run=dry_run)
133 cmd(['git', 'push', '--tags'], dry_run=dry_run)
136def commit_dependency_unfreeze(dry_run: bool) -> None:
137 """Commit the unfreezing of dependency upper bounds to ``main``.
139 :param dry_run: Whether command execution is disabled.
140 :type dry_run: bool
141 """
142 from mafw.devtools.documentation.requirements import REQUIREMENTS_GROUPS
144 CONSOLE.print('Committing dependency unfreeze...')
145 tracked_files: list[str] = [str(PYPROJECT_FILE), 'README.rst']
146 for group in REQUIREMENTS_GROUPS:
147 tracked_files.append(f'docs/source/requirements/{group}_requirements.rst')
149 cmd(['git', 'add', *tracked_files], dry_run=dry_run)
150 cmd(['git', 'commit', '-m', 'chore(dependencies): unfreeze upper bounds'], dry_run=dry_run)
153def get_git_tags(min_version: str | None = None) -> List[Tuple[Version, Any]]:
154 """Return list of (Version, tag) tuples sorted ascending.
156 :param min_version: Minimum version to consider, defaults to None
157 :type min_version: str | None
158 :return: List of (Version, tag) tuples
159 :rtype: List[Tuple[Version, Any]]
160 """
161 proc = cmd(['git', 'tag'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False)
162 tags = proc.stdout.split()
163 versions = []
164 for t in tags:
165 try:
166 v = Version(t)
167 if v.is_prerelease or v.is_devrelease:
168 continue
169 if min_version and v < Version(min_version):
170 continue
171 versions.append((v, t))
172 except InvalidVersion:
173 continue
174 versions.sort()
175 return versions
178def get_current_branch() -> str:
179 """Get the name of the currently checked out branch.
181 :return: Name of the current branch
182 :rtype: str
183 """
184 proc = cmd(
185 ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False
186 )
187 return str(proc.stdout.strip())
190def sort_tags_semver(tags: List[str]) -> List[str]:
191 """Sort tags using semantic versioning comparison.
193 :param tags: List of tag strings to sort
194 :type tags: List[str]
195 :return: Sorted list of tag strings
196 :rtype: List[str]
197 """
198 from mafw.devtools.documentation.builder import parse_version_tuple
200 return sorted(tags, key=lambda t: parse_version_tuple(t))
203def git_rev_of(ref: str) -> str:
204 """Get the git revision hash for a given reference.
206 :param ref: Git reference (tag, branch, commit hash)
207 :type ref: str
208 :return: Git revision hash
209 :rtype: str
210 :raises RuntimeError: If git rev-list fails
211 """
212 proc = cmd(['git', 'rev-list', '-n', '1', ref], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False)
213 if proc.returncode != 0:
214 raise RuntimeError(f'git rev-list failed for {ref}:\n{proc.stdout}')
215 return str(proc.stdout.strip())
218def is_ancestor(a: str, b: str) -> bool:
219 """Return True if commit a is ancestor of commit b (git merge-base --is-ancestor).
221 :param a: First commit reference
222 :type a: str
223 :param b: Second commit reference
224 :type b: str
225 :return: True if a is ancestor of b
226 :rtype: bool
227 """
228 proc = cmd(
229 ['git', 'merge-base', '--is-ancestor', a, b], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False
230 )
231 return proc.returncode == 0