repomatic.git_ops module

Git operations for GitHub Actions workflows.

This module provides utilities for common Git operations in CI/CD contexts, with idempotent behavior to allow safe re-runs of failed workflows.

All operations follow a “belt-and-suspenders” approach: combine workflow timing guarantees (e.g. workflow_run ensures tags exist) with idempotent guards (e.g. skip_existing on tag creation). This ensures correctness in the face of race conditions, API eventual consistency, and partial failures that are common in GitHub Actions.

Warning

Tag push requires REPOMATIC_PAT

Tags pushed with the default GITHUB_TOKEN do not trigger downstream on.push.tags workflows. The custom PAT is required so that tagging a release commit actually fires the publish and release creation jobs.

repomatic.git_ops.COMMIT_IDENTITY_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com'

Commit author email for automated commits: GitHub’s own Actions bot user.

The 41898282+ prefix is the bot’s stable user ID, which makes GitHub link the commit to the verified github-actions[bot] account.

repomatic.git_ops.COMMIT_IDENTITY_NAME = 'github-actions[bot]'

Commit author name for automated commits.

repomatic.git_ops.SHORT_SHA_LENGTH = 7

Default SHA length hard-coded to 7.

Caution

The default is subject to change and depends on the size of the repository.

repomatic.git_ops.GITHUB_REMOTE_PATTERN = re.compile('github\\.com[:/](?P<slug>[^/]+/[^/]+?)(?:\\.git)?$')

Extracts an owner/repo slug from a GitHub remote URL.

Handles both HTTPS (https://github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git) formats.

repomatic.git_ops.CHANGELOG_COMMIT_PREFIX = '[changelog] '

Marker prefix carried by every machine-authored version-machinery commit.

The one bracketed prefix commit messages may carry (see claude.md § Commit messages): release freezes, post-release bumps and manual version bumps all start with it, so a workflow can skip machinery pushes with a single startsWith(github.event.head_commit.message, '[changelog] ') clause instead of enumerating each message shape. Conformance tests in tests/test_workflows.py hold every member of VERSION_BUMP_COMMIT_PREFIXES, RELEASE_COMMIT_PATTERN, the bump-version template title, and the workflow gates to this prefix.

repomatic.git_ops.RELEASE_COMMIT_PREFIX = '[changelog] Release'

Head-commit-message prefix marking a push that carries the release commit.

The coarser sibling of RELEASE_COMMIT_PATTERN: where that one validates and extracts a version, this is the prefix test every workflow’s cancel-in-progress gate performs, so a release run is never cancelled by a later push entering its concurrency group. A prefix is deliberately weaker than the full pattern here, because the question is “does this push carry a release” rather than “which version is it”, and answering it must not depend on the version number parsing.

repomatic.github.actions.cancel_superseded_runs() applies the same test from the API side, which is the half GitHub’s own concurrency mechanism cannot cover: a manual sweep of a branch’s live runs enters no concurrency group at all.

repomatic.git_ops.RELEASE_COMMIT_PATTERN = re.compile('^\\[changelog\\] Release v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)$')

Pre-compiled regex for release commit messages.

Matches the full message and captures the version number. Use fullmatch to validate a commit is a release commit, or match/search with .group("version") to extract the version string.

A rebase merge preserves the original commit messages, so release commits match this pattern. A squash merge replaces them with the PR title (e.g. Release ``v1.2.3 (#42)``), which does not match. This mismatch is the mechanism by which squash merges are safely skipped: the create-tag job only processes commits matching this pattern, so no tag, PyPI publish, or GitHub release is created from a squash merge. The detect-squash-merge job in release.yaml detects this and opens an issue to notify the maintainer.

repomatic.git_ops.VERSION_BUMP_BRANCHES: frozenset[str] = frozenset({'major-version-increment', 'minor-version-increment', 'prepare-release'})

PR branches that carry only automated version-bump and lockfile churn.

Members are bot-authored draft PRs created by the bump-version and prepare-release jobs in changelog.yaml. Their working tree is byte-identical to main except for the version string in pyproject.toml, **/__init__.py, changelog.md, citation.cff, and uv.lock. Heavy PR-time workflows (tests.yaml, lint.yaml, labels.yaml) list these branches under pull_request.branches-ignore so the matrix doesn’t burn CI minutes for a guaranteed-passing run.

Note

These branches are not binary-neutral: the rewritten version string is baked into the Nuitka binary, so they are deliberately absent from repomatic.release.binary.SKIP_BINARY_BUILD_BRANCHES. Post-merge release artifacts on main are still produced.

repomatic.git_ops.MANUAL_VERSION_BUMP_COMMIT_PREFIXES: frozenset[str] = frozenset({'[changelog] Bump major version to ', '[changelog] Bump minor version to '})

Head-commit-message prefixes for user-initiated version bumps.

Members are the bump-version job’s [changelog] Bump $part version to \``v$version\ commit messages (rendered from the bump-version template’s title), carrying CHANGELOG_COMMIT_PREFIX like every other version-machinery commit. These merges land as a single commit on main and carry no other payload, so workflows can short-circuit on them safely.

The release-cycle prefix [changelog] Post-release bump is deliberately absent from this set because the prepare-release merge bundles the post-release-bump commit with the actual release commit ([changelog] Release vX.Y.Z) in a single push. Workflows that gate on the head commit message (tests.yaml, release.yaml::compile-binaries) must run on those pushes to test the release commit and build its binary — so they consult only this subset.

repomatic.git_ops.VERSION_BUMP_COMMIT_PREFIXES: frozenset[str] = frozenset({'[changelog] Bump major version to ', '[changelog] Bump minor version to ', '[changelog] Post-release bump '})

Full set of head-commit-message prefixes that mark a version-bump push.

Combines MANUAL_VERSION_BUMP_COMMIT_PREFIXES with the [changelog] Post-release bump prefix produced by prepare-release merges. Every member starts with CHANGELOG_COMMIT_PREFIX, so workflows without a release-artifact dependency (lint.yaml, labels.yaml, autofix.yaml) gate their metadata job on that single prefix and the entire job graph skips for any push generated by the version-bump PR family. Workflows that do produce release artifacts on the same push use MANUAL_VERSION_BUMP_COMMIT_PREFIXES instead.

repomatic.git_ops.GIT_LOG_FORMAT = '%H%x00%B'

git log pretty-format placeholders for a single commit: full SHA, then a NUL, then the raw body.

Paired with git log -z (which terminates each commit’s output with a NUL), this frames the stream as alternating (hash, message) tokens. Commit messages may contain newlines but never NUL bytes, so splitting on NUL recovers the fields unambiguously even for multi-line messages.

class repomatic.git_ops.Commit(hash: str, msg: str)[source]

Bases: NamedTuple

A minimal git commit.

Only the hash and message are ever consumed downstream, so a full git library object (with diffs, modified-file analysis, and complexity metrics) is unnecessary: the git CLI feeds these two fields directly.

Create new instance of Commit(hash, msg)

hash: str

The commit’s full 40-character SHA-1 hash.

msg: str

The commit message, stripped of surrounding whitespace.

repomatic.git_ops.get_commit(ref='HEAD')[source]

Return the commit at ref.

Raises:

subprocess.CalledProcessError – if ref does not resolve to a commit present in the repository.

Return type:

Commit

repomatic.git_ops.list_commits(start, end)[source]

Return the commits in the start..end range, oldest first.

Follows git range semantics: start is excluded, end is included. Both endpoints must already exist locally, so deepen a shallow clone before calling if necessary.

Return type:

tuple[Commit, ...]

repomatic.git_ops.commit_exists(ref)[source]

Return True if ref resolves to a commit object present locally.

Return type:

bool

repomatic.git_ops.count_commits(ref='HEAD')[source]

Return the number of commits reachable from ref.

Return type:

int

repomatic.git_ops.head_sha()[source]

Return the full SHA of the current HEAD commit.

Return type:

str

repomatic.git_ops.current_branch()[source]

Return the checked-out branch name, or None when HEAD is detached.

Return type:

str | None

repomatic.git_ops.checkout(ref)[source]

Check out ref (a branch name or commit SHA).

Return type:

None

repomatic.git_ops.restore_paths(ref, paths)[source]

Overwrite paths in the working tree with the content they hold at ref.

HEAD stays where it is, so the checkout keeps the source tree and lock file it was cloned with and only the named files travel. That is the whole point over a git checkout of the branch: a job reading a file back from an open pull request still runs the code of the branch it was called on.

A path ref does not carry is skipped rather than deleted. The caller names the files it means to read, and one missing from ref is the first-run case, not an instruction to remove anything.

Parameters:
  • ref (str) – Any tree-ish: a branch, a tag, or a commit SHA.

  • paths (Sequence[str | Path]) – Files to restore, absolute or relative to the current directory. One outside the working tree is skipped.

Return type:

tuple[str, ...]

Returns:

The paths actually restored, as repository-relative pathspecs.

repomatic.git_ops.stash()[source]

Stash the working tree’s local changes.

Return type:

None

repomatic.git_ops.stash_pop()[source]

Restore the most recently stashed local changes.

Return type:

None

repomatic.git_ops.stash_count()[source]

Return the number of entries on the stash reflog.

Return type:

int

repomatic.git_ops.fetch_deepen(depth)[source]

Deepen a shallow clone by fetching depth more commits.

Raises:

subprocess.CalledProcessError – if the fetch fails.

Return type:

None

repomatic.git_ops.diff_names(start, end)[source]

Return the paths that differ between start and end.

Raises:

subprocess.CalledProcessError – if either ref is unknown.

Return type:

tuple[str, ...]

repomatic.git_ops.tree_sha(ref='HEAD')[source]

Return the SHA of the tree ref points at.

Two commits sharing a tree SHA carry byte-identical content, whatever their message, author or parent. That makes this the cheapest way to ask whether re-running a generator produced anything new.

Return type:

str

repomatic.git_ops.count_commits_between(start, end)[source]

Return the number of commits in the start..end range.

Follows git range semantics: start is excluded, end is included.

Return type:

int

repomatic.git_ops.is_ancestor(maybe_ancestor, ref)[source]

Return whether maybe_ancestor is reachable from ref.

Return type:

bool | None

Returns:

True or False when git can relate the two commits, and None when it cannot — a shallow clone whose grafted history stops before their common ancestor answers neither yes nor no.

repomatic.git_ops.merge_base(left, right)[source]

Return the best common ancestor of two commits, or None if unrelated.

None also covers the shallow-clone case described in is_ancestor().

Return type:

str | None

repomatic.git_ops.rebase_onto(new_base, old_base, branch)[source]

Replay old_base..branch on top of new_base, keeping the replayed side.

--strategy-option=theirs resolves overlaps in favour of the commits being replayed, which for a generated branch means the freshly generated content wins over whatever the new base happens to carry.

Return type:

bool

Returns:

True on success. On conflict the rebase is aborted and False returned, leaving branch exactly as it was: a branch built on a slightly stale base still opens a usable pull request, and the next run converges it, so this is not worth failing the job over.

repomatic.git_ops.create_branch(name)[source]

Create or reset branch name at HEAD and switch to it.

The index and working tree carry over untouched, so staged changes made before the call survive into a commit made after it.

Return type:

None

repomatic.git_ops.delete_branch(name)[source]

Delete the local branch name, even when unmerged.

Tolerates a branch that is not there. Callers reach this from a finally that cleans up a scratch branch, where the failure being cleaned up after may be the very thing that stopped the branch from being created: raising here would replace the real error with a confusing one.

Return type:

None

repomatic.git_ops.stage_all(paths=())[source]

Stage working-tree changes, untracked files included.

Stages the whole tree by default. paths narrows that to a git pathspec list, for a job whose own steps leave more behind than they mean to commit: a linter installed into the checkout, a lock file a package manager rewrote on the way past. Anything outside the pathspec stays dirty and is left for the caller to restore or discard.

A pathspec matching nothing is dropped rather than fatal. git add exits 128 on the first one it cannot resolve and stages nothing at all, so a glob covering output a run happened not to produce would take the whole job down with it. Filtering first also keeps one stale entry in a list from silently costing the others their staging.

Parameters:

paths (Sequence[str]) – Git pathspecs to stage. Empty stages everything.

Return type:

bool

Returns:

True when the index ends up carrying something to commit.

repomatic.git_ops.commit_staged(message)[source]

Commit the staged tree as the CI bot and return the new commit SHA.

The identity is supplied per-command via -c, since CI checkouts carry no git identity of their own. Mirrors commit_and_push_files(), which does the same for the direct-to-default-branch case.

Return type:

str

repomatic.git_ops.fetch_remote_branch(branch, remote='origin')[source]

Fetch branch into its remote-tracking ref and return the SHA.

The refspec is explicit because actions/checkout configures a single-branch fetch refspec, under which a bare git fetch origin {branch} updates FETCH_HEAD but leaves refs/remotes/{remote}/{branch} absent. Writing the remote-tracking ref is what later lets a push take a --force-with-lease on it.

Return type:

str | None

Returns:

The remote branch tip, or None when the branch does not exist on the remote.

repomatic.git_ops.force_push_branch(local_ref, branch, expected_sha, remote='origin')[source]

Publish local_ref as branch on remote, overwriting what is there.

expected_sha is the remote tip the caller last observed, which becomes a --force-with-lease guard: the push is refused when the branch moved in between, rather than silently discarding the other writer’s commit. Pass None to create a branch that does not exist yet, where a plain push already fails if someone wins the race.

Raises:

subprocess.CalledProcessError – When the push is rejected.

Return type:

None

repomatic.git_ops.delete_remote_branch(branch, remote='origin')[source]

Delete branch from remote, tolerating a branch already gone.

Return type:

None

repomatic.git_ops.list_contributor_identities()[source]

Return every author and committer identity found in the history.

No normalization happens: all variations of author and committer strings attached to all commits are returned as-is, in Name <email> form.

For format output syntax, see: https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-aN

Raises:

RuntimeError – When git fails, carrying its stderr.

Return type:

set[str]

repomatic.git_ops.get_repo_slug_from_remote(remote='origin')[source]

Extract the owner/repo slug from a git remote URL.

Parses both HTTPS and SSH GitHub remote formats. Returns None if the remote is not set, not a GitHub URL, or git is unavailable.

Return type:

str | None

repomatic.git_ops.get_latest_tag_version()[source]

Returns the latest release version from Git tags.

Looks for tags matching the pattern vX.Y.Z and returns the highest version. Returns None if no matching tags are found.

Return type:

Version | None

repomatic.git_ops.get_release_version_from_commits(max_count=10)[source]

Extract release version from recent commit messages.

Searches recent commits for messages matching the pattern [changelog] Release vX.Y.Z and returns the version from the most recent match.

This provides a fallback when tags haven’t been pushed yet due to race conditions between workflows. The release commit message contains the version information before the tag is created.

Parameters:

max_count (int) – Maximum number of commits to search.

Return type:

Version | None

Returns:

The version from the most recent release commit, or None if not found.

repomatic.git_ops.get_all_version_tags()[source]

Get all version tags and their dates.

Runs a single git tag command to list all tags matching the vX.Y.Z pattern and extracts their dates.

Return type:

dict[str, str]

Returns:

Dict mapping version strings (without v prefix) to dates in YYYY-MM-DD format.

repomatic.git_ops.tag_exists(tag)[source]

Check if a Git tag already exists locally.

Parameters:

tag (str) – The tag name to check.

Return type:

bool

Returns:

True if the tag exists, False otherwise.

repomatic.git_ops.create_tag(tag, commit=None)[source]

Create a local Git tag.

Parameters:
  • tag (str) – The tag name to create.

  • commit (str | None) – The commit to tag. Defaults to HEAD.

Raises:

subprocess.CalledProcessError – If tag creation fails.

Return type:

None

repomatic.git_ops.push_tag(tag, remote='origin')[source]

Push a Git tag to a remote repository.

Parameters:
  • tag (str) – The tag name to push.

  • remote (str) – The remote name. Defaults to “origin”.

Raises:

subprocess.CalledProcessError – If push fails.

Return type:

None

repomatic.git_ops.commit_and_push_files(paths, message, remote='origin', branch='main', attempts=3, all_changes=False)[source]

Commit the given files and push, rebasing and retrying on rejection.

Designed for CI jobs that append to tracked files (scan records, the binaries page) and publish the result on the default branch. The commit is authored as COMMIT_IDENTITY_NAME via per-command -c config, since CI checkouts carry no git identity.

Idempotent: when the files are unchanged, no commit is created and the function returns False. A rejected push (another job or the maintainer pushed meanwhile) is retried after fetching and rebasing onto the fresh remote tip. Works from a detached HEAD: the push targets HEAD:{branch} explicitly.

Parameters:
  • paths (Sequence[Path | str]) – Files to stage and commit. Ignored when all_changes is set.

  • message (str) – Commit message.

  • remote (str) – Remote to push to.

  • branch (str) – Remote branch to push to.

  • attempts (int) – Maximum push attempts before giving up.

  • all_changes (bool) – Stage every change in the working tree instead of the named files. For a job whose output paths come from configuration and are therefore unknown to the workflow that runs it: the runner starts from a pristine checkout and the preceding steps are the only writers, so “everything that changed” is exactly the job’s own output. Never reach for it in a job that also runs a formatter or an installer.

Return type:

bool

Returns:

True when a commit was pushed, False when there was nothing to commit.

Raises:
repomatic.git_ops.create_and_push_tag(tag, commit=None, push=True, skip_existing=True)[source]

Create and optionally push a Git tag.

This function is idempotent: if the tag already exists and skip_existing is True, it returns False without failing. This allows safe re-runs of workflows that were interrupted after tag creation but before other steps.

Parameters:
  • tag (str) – The tag name to create.

  • commit (str | None) – The commit to tag. Defaults to HEAD.

  • push (bool) – Whether to push the tag to the remote. Defaults to True.

  • skip_existing (bool) – If True, skip silently when tag exists. If False, raise an error. Defaults to True.

Return type:

bool

Returns:

True if the tag was created, False if it already existed.

Raises: