Source code for semantic_release.gitproject

"""Module for git related operations."""

from __future__ import annotations

from contextlib import nullcontext
from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING

from git import GitCommandError, Repo

from semantic_release.cli.masking_filter import MaskingFilter
from semantic_release.cli.util import indented, noop_report
from semantic_release.errors import (
    GitAddError,
    GitCommitEmptyIndexError,
    GitCommitError,
    GitPushError,
    GitTagError,
)

if TYPE_CHECKING:
    from contextlib import _GeneratorContextManager
    from logging import Logger
    from typing import Sequence

    from git import Actor


[docs] class GitProject: def __init__( self, directory: Path | str = ".", commit_author: Actor | None = None, credential_masker: MaskingFilter | None = None, ) -> None: self._project_root = Path(directory).resolve() self._logger = getLogger(__name__) self._cred_masker = credential_masker or MaskingFilter() self._commit_author = commit_author @property def project_root(self) -> Path: return self._project_root @property def logger(self) -> Logger: return self._logger def _get_custom_environment( self, repo: Repo ) -> nullcontext[None] | _GeneratorContextManager[None]: """ git.custom_environment is a context manager but is not reentrant, so once we have "used" it we need to throw it away and re-create it in order to use it again """ return ( nullcontext() if not self._commit_author else repo.git.custom_environment( GIT_AUTHOR_NAME=self._commit_author.name, GIT_AUTHOR_EMAIL=self._commit_author.email, GIT_COMMITTER_NAME=self._commit_author.name, GIT_COMMITTER_EMAIL=self._commit_author.email, ) )
[docs] def is_dirty(self) -> bool: with Repo(str(self.project_root)) as repo: return repo.is_dirty()
[docs] def git_add( self, paths: Sequence[Path | str], force: bool = False, strict: bool = False, noop: bool = False, ) -> None: if noop: noop_report( indented( f"""\ would have run: git add {str.join(" ", [str(Path(p)) for p in paths])} """ ) ) return git_args = dict( filter( lambda k_v: k_v[1], # if truthy { "force": force, }.items(), ) ) with Repo(str(self.project_root)) as repo: # TODO: in future this loop should be 1 line: # repo.index.add(all_paths_to_add, force=False) # noqa: ERA001 # but since 'force' is deliberately ineffective (as in docstring) in gitpython 3.1.18 # we have to do manually add each filepath, and catch the exception if it is an ignored file for updated_path in paths: try: repo.git.add(str(Path(updated_path)), **git_args) except GitCommandError as err: # noqa: PERF203, acceptable performance loss err_msg = f"Failed to add path ({updated_path}) to index" if strict: self.logger.exception(str(err)) raise GitAddError(err_msg) from err self.logger.warning(err_msg)
[docs] def git_commit( self, message: str, date: int | None = None, commit_all: bool = False, no_verify: bool = False, noop: bool = False, ) -> None: git_args = dict( filter( lambda k_v: k_v[1], # if truthy { "a": commit_all, "m": message, "date": date, "no_verify": no_verify, }.items(), ) ) if noop: command = ( f"""\ GIT_AUTHOR_NAME={self._commit_author.name} \\ GIT_AUTHOR_EMAIL={self._commit_author.email} \\ GIT_COMMITTER_NAME={self._commit_author.name} \\ GIT_COMMITTER_EMAIL={self._commit_author.email} \\ """ if self._commit_author else "" ) # Indents the newlines so that terminal formatting is happy - note the # git commit line of the output is 24 spaces indented too # Only this message needs such special handling because of the newlines # that might be in a commit message between the subject and body indented_commit_message = message.replace("\n\n", "\n\n" + " " * 24) command += f"git commit -m '{indented_commit_message}'" command += "--all" if commit_all else "" command += "--no-verify" if no_verify else "" noop_report( indented( f"""\ would have run: {command} """ ) ) return with Repo(str(self.project_root)) as repo: has_index_changes = bool(repo.index.diff("HEAD")) has_working_changes = self.is_dirty() will_commit_files = has_index_changes or ( has_working_changes and commit_all ) if not will_commit_files: raise GitCommitEmptyIndexError("No changes to commit!") with self._get_custom_environment(repo): try: repo.git.commit(**git_args) except GitCommandError as err: self.logger.exception(str(err)) raise GitCommitError("Failed to commit changes") from err
[docs] def git_tag(self, tag_name: str, message: str, noop: bool = False) -> None: if noop: command = ( f"""\ GIT_AUTHOR_NAME={self._commit_author.name} \\ GIT_AUTHOR_EMAIL={self._commit_author.email} \\ GIT_COMMITTER_NAME={self._commit_author.name} \\ GIT_COMMITTER_EMAIL={self._commit_author.email} \\ """ if self._commit_author else "" ) command += f"git tag -a {tag_name} -m '{message}'" noop_report( indented( f"""\ would have run: {command} """ ) ) return with Repo(str(self.project_root)) as repo, self._get_custom_environment(repo): try: repo.git.tag("-a", tag_name, m=message) except GitCommandError as err: self.logger.exception(str(err)) raise GitTagError(f"Failed to create tag ({tag_name})") from err
[docs] def git_push_branch(self, remote_url: str, branch: str, noop: bool = False) -> None: if noop: noop_report( indented( f"""\ would have run: git push {self._cred_masker.mask(remote_url)} {branch} """ ) ) return with Repo(str(self.project_root)) as repo: try: repo.git.push(remote_url, branch) except GitCommandError as err: self.logger.exception(str(err)) raise GitPushError( f"Failed to push branch ({branch}) to remote" ) from err
[docs] def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None: if noop: noop_report( indented( f"""\ would have run: git push {self._cred_masker.mask(remote_url)} tag {tag} """ # noqa: E501 ) ) return with Repo(str(self.project_root)) as repo: try: repo.git.push(remote_url, "tag", tag) except GitCommandError as err: self.logger.exception(str(err)) raise GitPushError(f"Failed to push tag ({tag}) to remote") from err