Source code for repomatic.metadata.git
# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Git commit-range logic of {class}`~repomatic.metadata.core.Metadata`.
Resolves the commit range an event bundles, the release commits inside it,
what those commits changed, and the per-commit matrices built by rewinding
the checkout. This is the one concern that can touch the repository state,
always restoring it (see `_restored_worktree`).
"""
from __future__ import annotations
import logging
import subprocess
from contextlib import contextmanager, nullcontext
from functools import cached_property
from extra_platforms import is_github_ci
from ..git_ops import (
MANUAL_VERSION_BUMP_COMMIT_PREFIXES,
RELEASE_COMMIT_PATTERN,
SHORT_SHA_LENGTH,
Commit,
checkout,
commit_exists,
count_commits,
current_branch,
diff_names,
fetch_deepen,
get_commit,
head_sha,
list_commits,
stash,
stash_count,
stash_pop,
)
from ..github.actions import (
NULL_SHA,
WorkflowEvent,
)
from ..github.matrix import Matrix
from ..release.binary import (
BINARY_AFFECTING_PATHS,
SKIP_BINARY_BUILD_BRANCHES,
)
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence
from contextlib import AbstractContextManager
from pathlib import Path
from typing import Any
[docs]
class GitMetadata:
"""Commit ranges, changed files, and the per-commit matrices.
A concern mixin of {class}`~repomatic.metadata.core.Metadata`: never
instantiated on its own, and reads sibling concerns through `self`.
"""
if TYPE_CHECKING:
# Sibling-concern surface read through `self`: each stub mirrors
# the descriptor another mixin (or the assembled `Metadata`
# class) defines, so every concern type-checks on its own.
@cached_property
def event_type(self) -> WorkflowEvent | None:
"""See {class}`~repomatic.metadata.env.EnvironmentMetadata`."""
@staticmethod
def get_current_version() -> str | None:
"""See {class}`~repomatic.metadata.project.ProjectMetadata`."""
@cached_property
def github_event(self) -> dict[str, Any]:
"""See {class}`~repomatic.metadata.core.Metadata`."""
@cached_property
def head_branch(self) -> str | None:
"""See {class}`~repomatic.metadata.env.EnvironmentMetadata`."""
@cached_property
def script_entries(self) -> list[tuple[str, str, str]]:
"""See {class}`~repomatic.metadata.project.ProjectMetadata`."""
@cached_property
def sha(self) -> str | None:
"""See {class}`~repomatic.metadata.env.EnvironmentMetadata`."""
@cached_property
def workflow_files(self) -> list[Path]:
"""See {class}`~repomatic.metadata.core.Metadata`."""
@cached_property
def yaml_files(self) -> list[Path]:
"""See {class}`~repomatic.metadata.core.Metadata`."""
@cached_property
def zsh_files(self) -> list[Path]:
"""See {class}`~repomatic.metadata.core.Metadata`."""
[docs]
def git_stash_count(self) -> int:
"""Returns the number of stashes."""
count = stash_count()
logging.debug(f"Number of stashes in repository: {count}")
return count
[docs]
def git_deepen(
self, commit_hash: str, max_attempts: int = 10, deepen_increment: int = 50
) -> bool:
"""Deepen a shallow clone until the provided `commit_hash` is found.
Progressively fetches more commits from the current repository until the
specified commit is found or max attempts is reached.
Returns `True` if the commit was found, `False` otherwise.
"""
# Cache the current depth to avoid repeated subprocess calls.
current_depth: int | None = None
for attempt in range(max_attempts):
if commit_exists(commit_hash):
if attempt > 0:
logging.info(
f"Found commit {commit_hash} after {attempt} deepen "
"operation(s)."
)
return True
logging.debug(f"Commit {commit_hash} not found.")
# Only compute depth if not cached yet.
if current_depth is None:
current_depth = count_commits()
if attempt == max_attempts - 1:
# We've exhausted all attempts.
logging.error(
f"Cannot find commit {commit_hash} in repository after "
f"{max_attempts} deepen attempts. "
f"Final depth is {current_depth} commits."
)
return False
logging.info(f"Commit {commit_hash} not found at depth {current_depth}.")
logging.info(
f"Deepening by {deepen_increment} commits (attempt "
f"{attempt + 1}/{max_attempts})..."
)
try:
fetch_deepen(deepen_increment)
except subprocess.CalledProcessError as ex:
logging.error(f"Failed to deepen repository: {ex}")
return False
# Update cached depth after successful fetch.
current_depth = count_commits()
logging.debug(
f"Repository deepened successfully. New depth: {current_depth}"
)
return False
@contextmanager
def _restored_worktree(self) -> Iterator[None]:
"""Save the repository state, and restore it however the body exits.
Stashes any local changes and records the initial ref (the canonical
active branch name, like `main`, or the HEAD SHA when detached), then
checks out and unstashes on the way out, exception included: a
`checkout()` raising mid-scan must not leave the repository on a past
commit with the user's changes still stashed.
:raises RuntimeError: Outside a CI environment, where rewinding a
developer's checkout is never worth the metadata.
"""
if not is_github_ci():
raise RuntimeError(
"Local repository manipulations only allowed in CI environment"
)
init_ref = current_branch() or head_sha()
logging.debug(f"Initial commit reference: {init_ref}")
counter_before = self.git_stash_count()
logging.debug("Try to stash local changes before our series of checkouts.")
stash()
counter_after = self.git_stash_count()
logging.debug(
"Stash counter changes after 'git stash' command: "
f"{counter_before} -> {counter_after}"
)
assert counter_after >= counter_before
need_unstash = counter_after > counter_before
logging.debug(f"Need to unstash after checkouts: {need_unstash}")
try:
yield
finally:
logging.debug(f"Restore repository to {init_ref}.")
checkout(init_ref)
if need_unstash:
logging.debug("Unstash local changes that were previously saved.")
stash_pop()
[docs]
def commit_matrix(self, commits: Sequence[Commit] | None) -> Matrix | None:
"""Pre-compute a matrix of commits.
```{danger}
This method temporarily modifies the state of the repository to
compute version metadata from the past.
To prevent any loss of uncommitted data, it stashes local changes
before its checkouts and restores the initial state however the scan
exits, through {meth}`_restored_worktree`.
```
The list of commits is augmented with long and short SHA values, as well as
current version. Most recent commit is first, oldest is last.
Returns a ready-to-use matrix structure:
```{code-block} python
{
"commit": [
"346ce664f055fbd042a25ee0b7e96702e95",
"6f27db47612aaee06fdf08744b09a9f5f6c2",
],
"include": [
{
"commit": "346ce664f055fbd042a25ee0b7e96702e95",
"short_sha": "346ce66",
"current_version": "2.0.1",
},
{
"commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
"short_sha": "6f27db4",
"current_version": "2.0.0",
},
],
}
```
"""
if not commits:
return None
current_commit = head_sha()
# Whether we must go back in time in the git log and browse past
# commits: always with several commits, and with a single one only
# when HEAD is not already sitting on it.
past_commit_lookup = len(commits) > 1 or current_commit != commits[0].hash
restore: AbstractContextManager[None]
if past_commit_lookup:
logging.debug(
"We need to look into the commit history. Inspect the initial state "
"of the repository."
)
restore = self._restored_worktree()
else:
logging.debug(
"No need to look into the commit history: repository is already "
f"checked out at {current_commit}"
)
restore = nullcontext()
matrix = Matrix()
with restore:
for commit in commits:
if past_commit_lookup:
logging.debug(f"Checkout to commit {commit.hash}")
checkout(commit.hash)
commit_metadata = {
"commit": commit.hash,
"short_sha": commit.hash[:SHORT_SHA_LENGTH],
}
logging.debug(f"Extract project version at commit {commit.hash}")
current_version = self.get_current_version()
if current_version:
commit_metadata["current_version"] = current_version
matrix.add_variation("commit", [commit.hash])
matrix.add_includes(commit_metadata)
return matrix
[docs]
@cached_property
def changed_files(self) -> tuple[str, ...] | None:
"""Returns the list of files changed in the current event's commit range.
Uses `git diff --name-only` between the start and end of the commit range.
Returns `None` if no commit range is available (e.g., outside CI).
"""
if not self.commit_range:
return None
start, end = self.commit_range
if not start or not end:
return None
try:
return diff_names(start, end)
except subprocess.CalledProcessError as ex:
detail = ex.stderr.strip() if ex.stderr else ex
logging.warning(f"Failed to get changed files from git diff: {detail}")
return None
[docs]
@cached_property
def binary_affecting_paths(self) -> tuple[str, ...]:
"""Path prefixes that affect compiled binaries for this project.
Combines the static {data}`BINARY_AFFECTING_PATHS` (common files like
`pyproject.toml`, `uv.lock`, `tests/`) with project-specific source
directories derived from `[project.scripts]` in `pyproject.toml`.
For example, a project with `mpm = "meta_package_manager.__main__:main"`
adds `meta_package_manager/` as an affecting path. This makes the check
reusable across downstream repositories without hardcoding source directories.
"""
# Derive top-level source package directories from script entry points.
source_dirs: set[str] = set()
for _cli_id, module_id, _callable_id in self.script_entries:
# Extract top-level package: "meta_package_manager.__main__" β
# "meta_package_manager/".
top_package = module_id.split(".")[0]
source_dirs.add(f"{top_package}/")
return BINARY_AFFECTING_PATHS + tuple(sorted(source_dirs))
[docs]
@cached_property
def head_commit_message(self) -> str:
"""Returns `github.event.head_commit.message` from the event payload.
Set for `push` events. Empty string for events that do not carry a
head commit (`pull_request`, `schedule`, `workflow_dispatch`).
"""
head_commit = self.github_event.get("head_commit") or {}
return head_commit.get("message") or ""
[docs]
@cached_property
def yaml_changed(self) -> bool:
"""Returns `True` when the current event's commit range touches at
least one YAML file.
Lets per-job lint gates short-circuit on pushes / PRs that don't
touch YAML. Falls back to "repo contains any YAML file" when the
commit range is unavailable (`workflow_dispatch`), preserving the
existing behavior of those manual runs.
"""
if self.changed_files is None:
return bool(self.yaml_files)
return any(f.endswith((".yaml", ".yml")) for f in self.changed_files)
[docs]
@cached_property
def zsh_changed(self) -> bool:
"""Returns `True` when the current event's commit range touches at
least one Zsh file.
Falls back to "repo contains any Zsh file" when the commit range
is unavailable.
"""
if self.changed_files is None:
return bool(self.zsh_files)
zsh_set = set(self.zsh_files)
return any(f in zsh_set for f in self.changed_files)
[docs]
@cached_property
def workflows_changed(self) -> bool:
"""Returns `True` when the current event's commit range touches at
least one GitHub workflow file.
Falls back to "repo contains any workflow file" when the commit
range is unavailable.
"""
if self.changed_files is None:
return bool(self.workflow_files)
wf_set = set(self.workflow_files)
return any(f in wf_set for f in self.changed_files)
[docs]
@cached_property
def skip_binary_build(self) -> bool:
"""Returns `True` if binary builds should be skipped for this event.
Binary builds are expensive and time-consuming. This property identifies
contexts where the changes cannot possibly affect compiled binaries,
allowing workflows to skip Nuitka compilation jobs.
Three mechanisms are checked:
1. **Branch name** β PRs from known non-code branches (documentation,
`.mailmap`, `.gitignore`, etc.) are skipped.
2. **Version-bump commit** β Push events whose head commit is a
user-initiated version bump (`Bump (major|minor) version to `)
are skipped: the bump merge changes only version strings and
`uv.lock`, so the new binary differs from the previous one only
in the baked-in version string. The
`[changelog] Post-release bump ` prefix is deliberately *not*
checked here: the `prepare-release` merge bundles the release
commit with the post-release-bump commit, and the release
commit must still produce its binary.
3. **Changed files** β Push events where all changed files fall outside
{attr}`binary_affecting_paths` are skipped. This avoids ~2h of Nuitka
builds for documentation-only commits to `main`.
"""
if self.head_branch and self.head_branch in SKIP_BINARY_BUILD_BRANCHES:
logging.info(
f"Branch {self.head_branch!r} is in SKIP_BINARY_BUILD_BRANCHES. "
"Binary build will be skipped."
)
return True
if (
self.event_type == WorkflowEvent.push
and self.head_commit_message
and any(
self.head_commit_message.startswith(prefix)
for prefix in MANUAL_VERSION_BUMP_COMMIT_PREFIXES
)
):
logging.info(
"Head commit is a user-initiated version bump "
f"({self.head_commit_message.splitlines()[0]!r}). "
"Binary build will be skipped."
)
return True
# For push events, check if changed files affect binaries.
if self.event_type == WorkflowEvent.push and self.changed_files is not None:
affecting = self.binary_affecting_paths
if not self.changed_files:
# No changed files means nothing to build.
logging.info("No changed files detected. Binary build will be skipped.")
return True
if not any(
f.startswith(prefix) for f in self.changed_files for prefix in affecting
):
logging.info(
f"No changed files match binary-affecting paths {affecting!r}. "
"Binary build will be skipped."
)
return True
return False
[docs]
@cached_property
def commit_range(self) -> tuple[str | None, str] | None:
"""Range of commits bundled within the triggering event.
A workflow run is triggered by a singular event, which might encapsulate one or
more commits. This means the workflow will only run once on the last commit,
even if multiple new commits were pushed.
This is critical for releases where two commits are pushed together:
1. `[changelog] Release vX.Y.Z` β the release commit
2. `[changelog] Post-release bump vX.Y.Z β vX.Y.Z` β the post-release bump
Without extracting the full commit range, the release commit would be missed
since `github.event.head_commit` only exposes the post-release bump.
This property also enables processing each commit individually when we want to
keep a carefully constructed commit history. The typical example is a pull
request that is merged upstream but we'd like to produce artifacts (builds,
packages, etc.) for each individual commit.
The default `GITHUB_SHA` environment variable is not enough as it only points
to the last commit. We need to inspect the commit history to find all new ones.
New commits need to be fetched differently in `push` and `pull_request`
events.
```{seealso}
- https://stackoverflow.com/a/67204539
- https://stackoverflow.com/a/62953566
- https://stackoverflow.com/a/61861763
```
```{seealso}
Pull request events on GitHub are a bit complex, see: [The Many SHAs of a GitHub Pull Request](https://www.kenmuse.com/blog/the-many-shas-of-a-github-pull-request/).
```
"""
if not self.github_event or not self.event_type:
return None
# Pull request event.
if self.event_type in (
WorkflowEvent.pull_request,
WorkflowEvent.pull_request_target,
):
pr_data = self.github_event.get("pull_request", {})
start = pr_data.get("base", {}).get("sha")
# We need to checkout the HEAD commit instead of the artificial merge
# commit introduced by the pull request.
end = pr_data.get("head", {}).get("sha")
# Push event.
else:
start = self.github_event.get("before")
end = self.sha
logging.debug(f"Commit range: {start} -> {end}")
if not start or not end:
logging.warning(f"Incomplete commit range: {start} -> {end}")
return start, end
[docs]
@cached_property
def current_commit(self) -> Commit:
"""Returns the current `Commit` object.
Raises if `HEAD` cannot be resolved (an empty repository), mirroring the
previous behavior where traversing an empty history raised too.
"""
return get_commit("HEAD")
[docs]
@cached_property
def current_commit_matrix(self) -> Matrix | None:
"""Pre-computed matrix with long and short SHA values of the current commit."""
return self.commit_matrix((self.current_commit,))
[docs]
@cached_property
def new_commits(self) -> tuple[Commit, ...] | None:
"""Returns list of all `Commit` objects bundled within the triggering event.
This extracts **all commits** from the push event, not just `head_commit`.
For releases, this typically includes both the release commit and the
post-release bump commit, allowing downstream jobs to process each one.
Commits are returned in chronological order (oldest first, most recent last).
"""
if not self.commit_range:
return None
start, end = self.commit_range
# Treat the null SHA as no start commit. GitHub sends this value when a tag is
# created, since there is no previous commit to compare against.
if start == NULL_SHA:
logging.info(
f"Start commit is null SHA ({NULL_SHA}), treating as no start commit."
)
start = None
# Every branch below shells out to git, so mirror `changed_files`: a git
# failure (a compiled binary run over a checkout git rejects as "dubious
# ownership", a shallow clone that cannot be deepened) degrades to "range
# unknown" instead of crashing the whole `metadata` command.
try:
# Sanity check: make sure both ends of the range exist in the repository.
# Even though `start..end` excludes `start` from the result, git still
# needs `start` present locally to resolve the range and walk history.
for commit_id in (start, end):
if not commit_id:
continue
if not self.git_deepen(commit_id):
logging.warning(
"Skipping metadata extraction of the range of new commits."
)
return None
if not start:
logging.warning("No start commit found. Only one commit in range.")
assert end
return (get_commit(end),)
# The `start..end` range already excludes `start`, so every returned
# commit is a new one, in chronological order (oldest first).
return list_commits(start, end)
except subprocess.CalledProcessError as ex:
detail = ex.stderr.strip() if ex.stderr else ex
logging.warning(f"git failed while resolving new commits: {detail}")
return None
[docs]
@cached_property
def new_commits_matrix(self) -> Matrix | None:
"""Pre-computed matrix with long and short SHA values of new commits."""
return self.commit_matrix(self.new_commits)
[docs]
@cached_property
def new_commits_hash(self) -> tuple[str, ...] | None:
"""List all hashes of new commits."""
return self.new_commits_matrix["commit"] if self.new_commits_matrix else None
[docs]
@cached_property
def release_commits(self) -> tuple[Commit, ...] | None:
"""Returns list of `Commit` objects to be tagged within the triggering event.
This filters `new_commits` to find release commits that need special handling:
tagging, PyPI publishing, and GitHub release creation.
This is essential because when a release is pushed, `github.event.head_commit`
only exposes the post-release bump commit, not the release commit. By extracting
all commits from the event (via `new_commits`) and filtering for release
commits here, we ensure the release workflow can properly identify and process
the `[changelog] Release vX.Y.Z` commit.
We cannot identify a release commit based on the presence of a `vX.Y.Z` tag
alone. That's because the tag is not present in the `prepare-release` pull
request produced by the `changelog.yaml` workflow. The tag is created later
by the `release.yaml` workflow, when the pull request is merged to `main`.
Our best option is to identify a release based on the full commit message,
using the template from the `changelog.yaml` workflow.
"""
if not self.new_commits:
return None
return tuple(
commit
for commit in self.new_commits
if RELEASE_COMMIT_PATTERN.fullmatch(commit.msg)
)
[docs]
@cached_property
def release_commits_matrix(self) -> Matrix | None:
"""Pre-computed matrix with long and short SHA values of release commits."""
return self.commit_matrix(self.release_commits)
[docs]
@cached_property
def release_commits_hash(self) -> tuple[str, ...] | None:
"""List all hashes of release commits."""
return (
self.release_commits_matrix["commit"]
if self.release_commits_matrix
else None
)