X Tutup
from __future__ import annotations import os from enum import Enum from functools import lru_cache from pathlib import Path from tempfile import NamedTemporaryFile from commitizen import cmd, out from commitizen.exceptions import GitCommandError class EOLType(Enum): """The EOL type from `git config core.eol`.""" LF = "lf" CRLF = "crlf" NATIVE = "native" @classmethod def for_open(cls) -> str: c = cmd.run("git config core.eol") eol = c.out.strip().upper() return cls._char_for_open()[cls._safe_cast(eol)] @classmethod def _safe_cast(cls, eol: str) -> EOLType: try: return cls[eol] except KeyError: return cls.NATIVE @classmethod @lru_cache def _char_for_open(cls) -> dict[EOLType, str]: """Get the EOL character for `open()`.""" return { cls.LF: "\n", cls.CRLF: "\r\n", cls.NATIVE: os.linesep, } class GitObject: rev: str name: str date: str def __eq__(self, other: object) -> bool: return hasattr(other, "rev") and self.rev == other.rev def __hash__(self) -> int: return hash(self.rev) class GitCommit(GitObject): def __init__( self, rev: str, title: str, body: str = "", author: str = "", author_email: str = "", parents: list[str] | None = None, ) -> None: self.rev = rev.strip() self.title = title.strip() self.body = body.strip() self.author = author.strip() self.author_email = author_email.strip() self.parents = parents or [] @property def message(self) -> str: return f"{self.title}\n\n{self.body}".strip() @classmethod def from_rev_and_commit(cls, rev_and_commit: str) -> GitCommit: """Create a GitCommit instance from a formatted commit string. This method parses a multi-line string containing commit information in the following format: ``` <author> <author_email> <body_line_1> <body_line_2> ... ``` Args: rev_and_commit (str): A string containing commit information with fields separated by newlines. - rev: The commit hash/revision - parents: Space-separated list of parent commit hashes - title: The commit title/message - author: The commit author's name - author_email: The commit author's email - body: Optional multi-line commit body Returns: GitCommit: A new GitCommit instance with the parsed information. Example: >>> commit_str = '''abc123 ... def456 ghi789 ... feat: add new feature ... John Doe ... john@example.com ... This is a detailed description ... of the new feature''' >>> commit = GitCommit.from_rev_and_commit(commit_str) >>> commit.rev 'abc123' >>> commit.title 'feat: add new feature' >>> commit.parents ['def456', 'ghi789'] """ rev, parents, title, author, author_email, *body_list = ( rev_and_commit.splitlines() ) return cls( rev=rev.strip(), title=title.strip(), body="\n".join(body_list).strip(), author=author, author_email=author_email, parents=[p for p in parents.strip().split(" ") if p], ) def __repr__(self) -> str: return f"{self.title} ({self.rev})" class GitTag(GitObject): def __init__(self, name: str, rev: str, date: str) -> None: self.rev = rev.strip() self.name = name.strip() self._date = date.strip() def __repr__(self) -> str: return f"GitTag('{self.name}', '{self.rev}', '{self.date}')" @property def date(self) -> str: return self._date @date.setter def date(self, value: str) -> None: self._date = value @classmethod def from_line(cls, line: str, inner_delimiter: str) -> GitTag: name, objectname, date, obj = line.split(inner_delimiter) if not obj: obj = objectname return cls(name=name, rev=obj, date=date) def tag( tag: str, annotated: bool = False, signed: bool = False, msg: str | None = None ) -> cmd.Command: if not annotated and not signed: return cmd.run(f"git tag {tag}") # according to https://git-scm.com/book/en/v2/Git-Basics-Tagging, # we're not able to create lightweight tag with message. # by adding message, we make it a annotated tags option = "-s" if signed else "-a" # The else case is for annotated tags return cmd.run(f'git tag {option} {tag} -m "{msg or tag}"') def add(*args: str) -> cmd.Command: return cmd.run(f"git add {' '.join(args)}") def commit( message: str, args: str = "", committer_date: str | None = None, ) -> cmd.Command: f = NamedTemporaryFile("wb", delete=False) f.write(message.encode("utf-8")) f.close() command = _create_commit_cmd_string(args, committer_date, f.name) c = cmd.run(command) os.unlink(f.name) return c def _create_commit_cmd_string(args: str, committer_date: str | None, name: str) -> str: command = f'git commit {args} -F "{name}"' if not committer_date: return command if os.name != "nt": return f"GIT_COMMITTER_DATE={committer_date} {command}" # Using `cmd /v /c "{command}"` sets environment variables only for that command return f'cmd /v /c "set GIT_COMMITTER_DATE={committer_date}&& {command}"' def get_commits( start: str | None = None, end: str | None = None, *, args: str = "", ) -> list[GitCommit]: """Get the commits between start and end.""" if end is None: end = "HEAD" git_log_entries = _get_log_as_str_list(start, end, args) return [ GitCommit.from_rev_and_commit(rev_and_commit) for rev_and_commit in git_log_entries if rev_and_commit ] def get_filenames_in_commit(git_reference: str = "") -> list[str]: """Get the list of files that were committed in the requested git reference. :param git_reference: a git reference as accepted by `git show`, default: the last commit :returns: file names committed in the last commit by default or inside the passed git reference """ c = cmd.run(f"git show --name-only --pretty=format: {git_reference}") if c.return_code == 0: return c.out.strip().split("\n") raise GitCommandError(c.err) def get_tags( dateformat: str = "%Y-%m-%d", reachable_only: bool = False ) -> list[GitTag]: inner_delimiter = "---inner_delimiter---" formatter = ( f'"%(refname:strip=2){inner_delimiter}' f"%(objectname){inner_delimiter}" f"%(creatordate:format:{dateformat}){inner_delimiter}" f'%(object)"' ) extra = "--merged" if reachable_only else "" # Force the default language for parsing env = {"LC_ALL": "C", "LANG": "C", "LANGUAGE": "C"} c = cmd.run(f"git tag --format={formatter} --sort=-creatordate {extra}", env=env) if c.return_code != 0: if reachable_only and c.err == "fatal: malformed object name HEAD\n": # this can happen if there are no commits in the repo yet return [] raise GitCommandError(c.err) if c.err: out.warn(f"Attempting to proceed after: {c.err}") return [ GitTag.from_line(line=line, inner_delimiter=inner_delimiter) for line in c.out.split("\n")[:-1] ] def tag_exist(tag: str) -> bool: c = cmd.run(f"git tag --list {tag}") return tag in c.out def is_signed_tag(tag: str) -> bool: return cmd.run(f"git tag -v {tag}").return_code == 0 def get_latest_tag_name() -> str | None: c = cmd.run("git describe --abbrev=0 --tags") if c.err: return None return c.out.strip() def get_tag_message(tag: str) -> str | None: c = cmd.run(f"git tag -l --format='%(contents:subject)' {tag}") if c.err: return None return c.out.strip() def get_tag_names() -> list[str]: c = cmd.run("git tag --list") if c.err: return [] return [tag for raw in c.out.split("\n") if (tag := raw.strip())] def find_git_project_root() -> Path | None: c = cmd.run("git rev-parse --show-toplevel") if c.err: return None return Path(c.out.strip()) def is_staging_clean() -> bool: """Check if staging is clean.""" c = cmd.run("git diff --no-ext-diff --cached --name-only") return not bool(c.out) def is_git_project() -> bool: c = cmd.run("git rev-parse --is-inside-work-tree") return c.out.strip() == "true" def get_core_editor() -> str | None: c = cmd.run("git var GIT_EDITOR") if c.out: return c.out.strip() return None def smart_open(*args, **kwargs): # type: ignore[no-untyped-def,unused-ignore] # noqa: ANN201 """Open a file with the EOL style determined from Git.""" return open(*args, newline=EOLType.for_open(), **kwargs) def _get_log_as_str_list(start: str | None, end: str, args: str) -> list[str]: """Get string representation of each log entry""" delimiter = "----------commit-delimiter----------" log_format: str = "%H%n%P%n%s%n%an%n%ae%n%b" command_range = f"{start}..{end}" if start else end command = f"git -c log.showSignature=False log --pretty={log_format}{delimiter} {args} {command_range}" c = cmd.run(command) if c.return_code != 0: raise GitCommandError(c.err) return c.out.split(f"{delimiter}\n") def get_default_branch() -> str: c = cmd.run("git symbolic-ref refs/remotes/origin/HEAD") if c.return_code != 0: raise GitCommandError(c.err) return c.out.strip()
X Tutup