From c3535bde50456c2cefcd11d1b18daa6059fbacb7 Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Thu, 30 May 2024 23:18:50 +0200 Subject: [PATCH 1/2] introduce snippets --- .github/workflows/release.yml | 5 +- .github/workflows/test-release.yaml | 5 +- .github/workflows/test.yml | 5 +- .gitignore | 5 +- .snippets/3.md | 34 ++++++ README.md | 12 ++ poetry.lock | 2 +- pyproject.toml | 6 +- snippets2changelog/cli.py | 36 ++++-- snippets2changelog/collector.py | 114 ++++++++++++++++++ snippets2changelog/creator.py | 108 ++++++++++++++--- snippets2changelog/parser.py | 41 ++++--- .../templates/changelog.md.template | 5 + .../templates/changelog_part.md.template | 4 + 14 files changed, 327 insertions(+), 55 deletions(-) create mode 100644 .snippets/3.md create mode 100644 snippets2changelog/collector.py create mode 100644 snippets2changelog/templates/changelog.md.template create mode 100644 snippets2changelog/templates/changelog_part.md.template diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6867e7..206892a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,8 +39,11 @@ jobs: echo "latest_version=$latest_version" >> $GITHUB_ENV - name: Build package run: | + poetry run changelog-generator \ + changelog changelog.md \ + --snippets=.snippets poetry run changelog2version \ - --changelog_file changelog.md \ + --changelog_file changelog.md.new \ --version_file snippets2changelog/version.py \ --version_file_type py \ --debug diff --git a/.github/workflows/test-release.yaml b/.github/workflows/test-release.yaml index d21120b..93c7e7e 100644 --- a/.github/workflows/test-release.yaml +++ b/.github/workflows/test-release.yaml @@ -36,8 +36,11 @@ jobs: echo "latest_version=$latest_version" >> $GITHUB_ENV - name: Build package run: | + poetry run changelog-generator \ + changelog changelog.md \ + --snippets=.snippets poetry run changelog2version \ - --changelog_file changelog.md \ + --changelog_file changelog.md.new \ --version_file snippets2changelog/version.py \ --version_file_type py \ --additional_version_info="-rc${{ github.run_number }}.dev${{ github.event.number }}" \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 042f777..bbe6fee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,8 +45,11 @@ jobs: poetry self add "poetry-dynamic-versioning[plugin]" - name: Build package run: | + poetry run changelog-generator \ + changelog changelog.md \ + --snippets=.snippets poetry run changelog2version \ - --changelog_file changelog.md \ + --changelog_file changelog.md.new \ --version_file snippets2changelog/version.py \ --version_file_type py \ --debug diff --git a/.gitignore b/.gitignore index 784a066..6e7b135 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# snippets2changelog specific +changelog.md.new + # custom, package specific ignores .DS_Store .DS_Store? @@ -114,7 +117,7 @@ celerybeat.pid # Environments .env -.venv +.venv* env/ venv/ ENV/ diff --git a/.snippets/3.md b/.snippets/3.md new file mode 100644 index 0000000..9bf1951 --- /dev/null +++ b/.snippets/3.md @@ -0,0 +1,34 @@ +## Introduce snippets + + +### Added +- `ChangelogCreator` class to render new changelog from snippets and existing base changelog +- New `changelog-generator changelog` CLI interface available and documented +- `SnippetCollector` to provide iterable of snippets found in specified folder, sorted by the appearance in the Git history (oldest first) +- Template for single changelog entry +- Template for complete rendered and updated changelog + +### Changed +#### Breaking +- `SnippetCreator` class has no `file_name` init parameter anymore + - `content` property renamed to `parsed_content` + - `parse` function takes `file_name` parameter + - `parsed_content` gets resetted with every new `parse(file_name)` call + - `_required_keys` renamed to `_required_parser_keys` +- `SnippetCreator` class has no `file_name` and `content` init parameter anymore + - `snippets_file` property removed + - `content` property renamed to `rendered_content` + - `render` function takes `content` parameter and returns `None` + - `create` function takes `file_name` parameter + +#### Other +- `changelog2version` moved from dev dependency to package dependency +- Create changelog from snippets in GitHub workflow before creating the python package + +### Fixed +- Sorted package dependencies in `pyproject.toml` +- Ignore all `.venv*` directories diff --git a/README.md b/README.md index aa9bef3..99522e2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Create version info files based on the latest changelog entry. - [Usage](#usage) - [Info](#info) - [Create](#create) + - [Snippet](#snippet) + - [Changelog](#changelog) - [Parse](#parse) - [Contributing](#contributing) - [Setup](#setup) @@ -46,6 +48,7 @@ changelog-generator info ``` ### Create +#### Snippet Create a new snippet with the given name at the specified snippets folder @@ -74,6 +77,15 @@ TBD ``` +#### Changelog + +Create or update a changelog with all snippets. +New changelog will be named `` + +```bash +changelog-generator changelog changelog.md --snippets=.snippets +``` + ### Parse Parse an existing snippet file and return the data as JSON without indentation diff --git a/poetry.lock b/poetry.lock index 912dad5..959df67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -846,4 +846,4 @@ dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "sphinx"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "040e37cb7494fdb355d979dee00732d62562c55573c89ac11c6977b2348e0092" +content-hash = "3fbe53fb75f3e616a208b07089756881b91446a1f20526f9b12dfe25b63d35dd" diff --git a/pyproject.toml b/pyproject.toml index becb889..884a4e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,14 +40,14 @@ format-jinja = """{{ check_output(["python3", "-c", "from pathlib import Path; e changelog-generator = 'snippets2changelog.cli:main' [tool.poetry.dependencies] -python = "^3.11" -pyyaml = "~6.0" +changelog2version = "^0.10" GitPython = "~3.1.43" jinja2 = "^3.1.4" +python = "^3.11" +pyyaml = "~6.0" [tool.poetry.group.dev.dependencies] black = "*" -changelog2version = "^0.10" flake8 = "*" isort = "*" mypy = "*" diff --git a/snippets2changelog/cli.py b/snippets2changelog/cli.py index affea00..c62ea23 100755 --- a/snippets2changelog/cli.py +++ b/snippets2changelog/cli.py @@ -12,7 +12,7 @@ from typing import Sequence from .common import LOG_LEVELS, collect_user_choice -from .creator import SnippetCreator +from .creator import ChangelogCreator, SnippetCreator from .parser import SnippetParser LOGGER_FORMAT = '[%(asctime)s] [%(levelname)-8s] [%(filename)-15s @'\ @@ -50,6 +50,22 @@ def parse_args(argv: Sequence[str] | None = None) -> Args: ) parser_info.set_defaults(func=fn_info) + parser_changelog = subparsers.add_parser( + "changelog", + help="Create a changelog", + ) + parser_changelog.set_defaults(func=fn_changelog) + parser_changelog.add_argument( + "changelog", + type=Path, + help="Path to existing changelog", + ) + parser_changelog.add_argument( + "--snippets", + type=lambda x: does_exist(parser, x), + help="Directory to crawl for snippets", + ) + parser_create = subparsers.add_parser( "create", help="Create a snippet", @@ -97,6 +113,11 @@ def fn_info(_args: Args) -> None: print(f"Version: {extract_version()}") +def fn_changelog(args: Args) -> None: + cc = ChangelogCreator(changelog=args.changelog, snippets_folder=args.snippets, verbosity=args.verbose) + cc.update_changelog() + + def fn_create(args: Args) -> None: content = { "short_description": input("Short description: "), @@ -107,15 +128,16 @@ def fn_create(args: Args) -> None: "affected": input("Affected users (default all): ") or "all", "content": "TBD", } - sc = SnippetCreator(file_name=args.name, content=content) - logger.debug(f"rendered content: >>>>>>\n{sc.render()}\n<<<<<<") - sc.create() + sc = SnippetCreator() + sc.render(content=content) + logger.debug(f"rendered content: >>>>>>\n{sc.rendered_content}\n<<<<<<") + sc.create(file_name=args.name) def fn_parse(args: Args) -> None: - sp = SnippetParser(file_name=args.name, verbosity=args.verbose) - sp.parse() - print(json.dumps(sp.content, indent=args.indent)) + sp = SnippetParser(verbosity=args.verbose) + sp.parse(file_name=args.name) + print(json.dumps(sp.parsed_content, indent=args.indent)) def main() -> int: diff --git a/snippets2changelog/collector.py b/snippets2changelog/collector.py new file mode 100644 index 0000000..d2d94df --- /dev/null +++ b/snippets2changelog/collector.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 + +"""Snippets collector""" + +from collections.abc import Iterator +from pathlib import Path + +from git import Commit, GitCmdObjectDB, Repo, Submodule, TagReference +from git.refs.head import Head + + +class CollectorError(Exception): + """Base class for exceptions in this module.""" + + pass + + +class HistoryWalker(object): + """docstring for HistoryWalker""" + + def __init__( + self, repo: Path, search_parent_directories: bool = True, branch_only: bool = True + ) -> None: + if repo.exists(): + try: + self._repo = Repo(repo, search_parent_directories=search_parent_directories) + except Exception as e: + raise CollectorError(e) + else: + raise CollectorError(f"Given repo folder '{repo}' does not exist") + + # limit amount of commits to crawl to a positive number + self._max_count = None + self._branch_only = branch_only + + @property + def repo_root(self) -> Path: + return Path(self._repo.working_dir) + + @property + def branch_name(self) -> Head: + return self._repo.active_branch + + @property + def tags(self) -> list[TagReference]: + return sorted(self._repo.tags, key=lambda t: t.commit.committed_datetime) + + def commits(self) -> Iterator[Commit]: + kwargs = dict() + + if self._branch_only: + # Collect the commits on this branch only + # aka ignore commits on other branches + kwargs["first-parent"] = True + + # latest commit is first element + for ele in self._repo.iter_commits( + rev=self.branch_name, + max_count=self._max_count, + **kwargs, + ): + yield ele + + +class SnippetCollector(HistoryWalker): + """docstring for SnippetCollector""" + + def __init__(self, snippets_folder: Path, file_extension: str = "md", **kwargs) -> None: + if snippets_folder.exists(): + self._snippets_folder = snippets_folder + else: + raise CollectorError(f"Given snippets folder '{snippets_folder}' does not exist") + + HistoryWalker.__init__(self, repo=self._snippets_folder, **kwargs) + self._file_extension = file_extension + + @property + def snippets_folder(self) -> Path: + return self._snippets_folder + + def all_snippet_files(self) -> Iterator[Path]: + """Get all potential snippet files from the snippets folder""" + for file in self._snippets_folder.iterdir(): + if file.is_file() and (file.suffix == ".{}".format(self._file_extension)): + yield file + + def snippets(self) -> Iterator[tuple[Commit, Path]]: + collected_snippets = list(self.all_snippet_files()) + + # nice chaos :) + # close to midnight, stop now or the problem of tomorrow will catch you + + # self._logger.debug(f"Repo root: {self.repo_root}") + # changelog-generator/ + + # self._logger.debug(f"collected_snippets: {collected_snippets}, looking for {self.snippets_folder}") + # collected_snippets: [PosixPath('.snippets/3.md')], looking for .snippets + + # use reversed to have oldest commit as first element + for idx, commit in reversed(list(enumerate(self.commits()))): + for file in commit.stats.files.keys(): + # self._logger.debug(f"{idx}: {commit}, looking for file: {file} in {collected_snippets}") + # 0: b768d6983432b730d81b34d125a4bbefb0a66525, looking for file: .snippets/3.md in [PosixPath('.snippets/3.md')] + # self._logger.debug(Path(file) in collected_snippets) + # True + """ + if self.snippets_folder / file in collected_snippets: + self._logger.warning(f"file {self.snippets_folder / file} is a match") + yield (commit, self.snippets_folder / file) + """ + if Path(file) in collected_snippets: + # self._logger.debug(f"file {file} is a match") + # file .snippets/3.md is a match + yield (commit, Path(file)) diff --git a/snippets2changelog/creator.py b/snippets2changelog/creator.py index fdc45ab..e36c7dd 100644 --- a/snippets2changelog/creator.py +++ b/snippets2changelog/creator.py @@ -4,10 +4,15 @@ import logging from pathlib import Path +from typing import Iterator +from changelog2version.extract_version import ExtractVersion # type: ignore +from git import Commit from jinja2 import Environment, FileSystemLoader -from .common import LOG_LEVELS, save_file +from .collector import SnippetCollector +from .common import LOG_LEVELS, read_file, save_file +from .parser import SnippetParser class SnippetCreatorError(Exception): @@ -16,39 +21,102 @@ class SnippetCreatorError(Exception): pass +class ChangelogCreatorError(Exception): + """Base class for exceptions in this module.""" + + pass + + class SnippetCreator(object): """docstring for SnippetCreator""" - def __init__(self, file_name: Path, content: dict[str, str], verbosity: int = 0) -> None: - _required_keys = ("short_description", "type", "scope", "affected", "content") - if all(k in content for k in _required_keys): - self._snippet_content = content - else: - raise SnippetCreatorError( - f"Not all required keys are given to render the snippet. Required keys: {_required_keys}" - ) + def __init__(self, verbosity: int = 0) -> None: + self._required_render_keys = ("short_description", "type", "scope", "affected", "content") + self._rendered_content = "" - self._file_name = file_name self._template_folder = (Path(__file__).parent / "templates").resolve() self._snippet_template = "snippet.md.template" self._env = Environment( - loader=FileSystemLoader(searchpath=self._template_folder), keep_trailing_newline=True + loader=FileSystemLoader(searchpath=self._template_folder), + keep_trailing_newline=True ) self._logger = logging.getLogger(__name__) self._logger.setLevel(level=LOG_LEVELS[min(verbosity, max(LOG_LEVELS.keys()))]) @property - def snippets_file(self) -> Path: - return self._file_name + def rendered_content(self) -> str: + return self._rendered_content - @property - def content(self) -> dict[str, str]: - return self._snippet_content + def render(self, content: dict[str, str]) -> None: + if all(k in content for k in self._required_render_keys): + self._rendered_content = self._env.get_template(str(self._snippet_template)).render(content) + else: + raise SnippetCreatorError( + f"Not all required keys are given to render the snippet. Required keys: {self._required_render_keys}" + ) + + def create(self, file_name: Path) -> None: + save_file(content=self.rendered_content, path=file_name) + + +class ChangelogCreator(ExtractVersion, SnippetParser, SnippetCreator, SnippetCollector): # type: ignore + """docstring for ChangelogCreator""" - def render(self) -> str: - return self._env.get_template(str(self._snippet_template)).render(self.content) + def __init__(self, changelog: Path, snippets_folder: Path, verbosity: int = 0) -> None: + if changelog.exists(): + self._changelog = changelog + else: + raise ChangelogCreatorError(f"Given changelog '{changelog}' does not exist") + + self._logger = logging.getLogger(__name__) + self._logger.setLevel(level=LOG_LEVELS[min(verbosity, max(LOG_LEVELS.keys()))]) - def create(self) -> None: - save_file(content=self.render(), path=self.snippets_file) + ExtractVersion.__init__(self, logger=self._logger) + SnippetParser.__init__(self, verbosity=verbosity) + SnippetCreator.__init__(self, verbosity=verbosity) + SnippetCollector.__init__(self, snippets_folder=snippets_folder) + + self._version_line = self.parse_changelog(changelog_file=self._changelog) + self._logger.debug(f"version_line: {self._version_line}") + # "## [0.1.0] - 2024-05-30" + + _ = self.parse_semver_line(release_version_line=self._version_line) + self._logger.debug(("semver_data:", self.semver_data)) + # VersionInfo(major=0, minor=1, patch=0, prerelease=None, build=None)) + + def update_changelog(self) -> None: + new_changelog_content = "" + # create a "prolog" and an "epilog", with the new content in between + existing_changelog_content = read_file(path=self._changelog, parse="read").split(self._version_line) + + for commit, file_name in self.snippets(): + self._logger.debug(f"Parsing {file_name}") + self.parse(file_name=file_name) + snippet_content = self.parsed_content + self._logger.debug(snippet_content) + if snippet_content["type"] == "bugfix": + self.semver_data = self.semver_data.bump_patch() + elif snippet_content["type"] == "feature": + self.semver_data = self.semver_data.bump_minor() + elif snippet_content["type"] == "breaking": + self.semver_data = self.semver_data.bump_major() + else: + raise ChangelogCreatorError(f"Invalid version change type: {snippet_content['type']}") + + changelog_entry_content = { + "version": self.semver_data, + "timestamp": commit.committed_datetime.isoformat(), + "content": snippet_content["details"], + "version_reference": f"https://github.com/brainelectronics/snippets2changelog/tree/{self.semver_data}", + } + self._logger.debug(f"changelog_entry_content: {changelog_entry_content}") + + changelog_entry = self._env.get_template("changelog_part.md.template").render(changelog_entry_content) + self._logger.debug(f"rendered changelog_entry: \n{changelog_entry}") + new_changelog_content = changelog_entry + new_changelog_content + + rendered_changelog = self._env.get_template("changelog.md.template").render({"prolog": existing_changelog_content[0], "new": new_changelog_content, "existing": self._version_line + existing_changelog_content[1]}) + rendered_changelog_path = Path(f"{self._changelog}.new") + save_file(content=rendered_changelog, path=rendered_changelog_path) diff --git a/snippets2changelog/parser.py b/snippets2changelog/parser.py index 9db09a8..6421343 100644 --- a/snippets2changelog/parser.py +++ b/snippets2changelog/parser.py @@ -20,28 +20,29 @@ class SnippetParserError(Exception): class SnippetParser(object): """docstring for SnippetCreator""" - def __init__(self, file_name: Path, additional_keys: tuple[str] | tuple[()] = (), verbosity: int = 0) -> None: - if file_name.exists(): - self._file_name = file_name - else: - raise SnippetParserError(f"Given snippets '{file_name}' does not exist") - - self._required_keys = ("type", "scope", "affected") + additional_keys - self._content = dict(zip(self._required_keys, [""] * len(self._required_keys))) - + def __init__(self, additional_keys: tuple[str] | tuple[()] = (), verbosity: int = 0) -> None: + self._file_name = Path() + self._required_parser_keys = ("type", "scope", "affected") + additional_keys + self._parsed_content = dict(zip(self._required_parser_keys, [""] * len(self._required_parser_keys))) self._logger = logging.getLogger(__name__) self._logger.setLevel(level=LOG_LEVELS[min(verbosity, max(LOG_LEVELS.keys()))]) @property - def content(self) -> dict[str, str]: - return self._content + def parsed_content(self) -> dict[str, str]: + return self._parsed_content + + def parse(self, file_name: Path) -> None: + # don't forget to clear the content before the next run + self._parsed_content = dict(zip(self._required_parser_keys, [""] * len(self._required_parser_keys))) + + if not file_name.exists(): + raise SnippetParserError(f"Given snippets '{file_name}' does not exist") - def parse(self) -> None: - file_content = read_file(self._file_name, parse="read") + file_content = read_file(file_name, parse="read") header_match = re.search(r"(^##\s)(.*)", file_content, re.MULTILINE) if header_match: - self._content["title"] = header_match.groups()[-1] + self._parsed_content["title"] = header_match.groups()[-1] matches = re.finditer(COMMENT_PATTERN, file_content, re.MULTILINE) match_found = False @@ -50,7 +51,7 @@ def parse(self) -> None: end = match.end() self._logger.debug(f"match: \n{match.group()}") found_keys = list() - for key in self._required_keys: + for key in self._required_parser_keys: info_matches = re.finditer(rf"({key}:\s)(.*)", match.group(), re.MULTILINE) for key_match in info_matches: data = key_match.groups()[-1] @@ -58,14 +59,14 @@ def parse(self) -> None: data = [x.strip() for x in data.split(",")] # do not overwrite already existing data - if not self._content[key]: - self._content[key] = data + if not self._parsed_content[key]: + self._parsed_content[key] = data found_keys.append(key) - self._logger.debug(f"processed: '{key}' as '{data}', found_keys: {found_keys}") + self._logger.debug(f"processed: '{key}' as '{data}', found_keys: {found_keys}, required: {self._required_parser_keys}") - if sorted(self._required_keys) == sorted(found_keys): + if sorted(self._required_parser_keys) == sorted(found_keys): self._logger.debug("All required keys found, taking everything else as details content") match_found = True if match_found: - self._content["details"] = file_content[end:] + self._parsed_content["details"] = file_content[end:] break diff --git a/snippets2changelog/templates/changelog.md.template b/snippets2changelog/templates/changelog.md.template new file mode 100644 index 0000000..60eeb10 --- /dev/null +++ b/snippets2changelog/templates/changelog.md.template @@ -0,0 +1,5 @@ +{{ prolog -}} + +{{ new }} + +{{- existing }} \ No newline at end of file diff --git a/snippets2changelog/templates/changelog_part.md.template b/snippets2changelog/templates/changelog_part.md.template new file mode 100644 index 0000000..2ed8fdc --- /dev/null +++ b/snippets2changelog/templates/changelog_part.md.template @@ -0,0 +1,4 @@ +## [{{ version }}] - {{ timestamp }} +{{- content }} +[{{ version }}]: {{ version_reference }} + From 0c2408d44cbeb6e384c2e7efd34c230a82e4ee4a Mon Sep 17 00:00:00 2001 From: Jonas Scharpf Date: Fri, 31 May 2024 00:02:33 +0200 Subject: [PATCH 2/2] try fixing detached head and set fetch-depth to all history --- .github/workflows/release.yml | 5 ++++- .github/workflows/test-release.yaml | 5 ++++- .github/workflows/test.yml | 3 +++ .github/workflows/unittest.yaml | 9 +++++++-- snippets2changelog/collector.py | 8 ++++++-- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 206892a..19ca915 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 + with: + # all history is needed to crawl it properly + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v3 with: diff --git a/.github/workflows/test-release.yaml b/.github/workflows/test-release.yaml index 93c7e7e..25e7da8 100644 --- a/.github/workflows/test-release.yaml +++ b/.github/workflows/test-release.yaml @@ -15,7 +15,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 + with: + # all history is needed to crawl it properly + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v3 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bbe6fee..0beaa96 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 + with: + # all history is needed to crawl it properly + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v3 with: diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml index 9d77254..cd8739f 100644 --- a/.github/workflows/unittest.yaml +++ b/.github/workflows/unittest.yaml @@ -14,8 +14,13 @@ jobs: test-and-coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v3 + - name: Checkout + uses: actions/checkout@v3 + with: + # all history is needed to crawl it properly + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v3 with: python-version: '3.11' - name: Execute tests diff --git a/snippets2changelog/collector.py b/snippets2changelog/collector.py index d2d94df..24d6432 100644 --- a/snippets2changelog/collector.py +++ b/snippets2changelog/collector.py @@ -38,8 +38,12 @@ def repo_root(self) -> Path: return Path(self._repo.working_dir) @property - def branch_name(self) -> Head: - return self._repo.active_branch + def branch_name(self) -> str: + try: + return str(self._repo.active_branch) + except Exception as e: + print(f"HEAD is detached: {e}") + return str(self._repo.head.commit.hexsha) @property def tags(self) -> list[TagReference]: