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 verifiedgithub-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/reposlug 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 singlestartsWith(github.event.head_commit.message, '[changelog] ')clause instead of enumerating each message shape. Conformance tests intests/test_workflows.pyhold every member ofVERSION_BUMP_COMMIT_PREFIXES,RELEASE_COMMIT_PATTERN, thebump-versiontemplate 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’scancel-in-progressgate 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
fullmatchto validate a commit is a release commit, ormatch/searchwith.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: thecreate-tagjob only processes commits matching this pattern, so no tag, PyPI publish, or GitHub release is created from a squash merge. Thedetect-squash-mergejob inrelease.yamldetects 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-versionandprepare-releasejobs inchangelog.yaml. Their working tree is byte-identical tomainexcept for the version string inpyproject.toml,**/__init__.py,changelog.md,citation.cff, anduv.lock. Heavy PR-time workflows (tests.yaml,lint.yaml,labels.yaml) list these branches underpull_request.branches-ignoreso 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 onmainare 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-versionjob’s[changelog] Bump $part version to \``v$version\commit messages (rendered from thebump-versiontemplate’s title), carryingCHANGELOG_COMMIT_PREFIXlike every other version-machinery commit. These merges land as a single commit onmainand carry no other payload, so workflows can short-circuit on them safely.The release-cycle prefix
[changelog] Post-release bumpis deliberately absent from this set because theprepare-releasemerge 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_PREFIXESwith the[changelog] Post-release bumpprefix produced byprepare-releasemerges. Every member starts withCHANGELOG_COMMIT_PREFIX, so workflows without a release-artifact dependency (lint.yaml,labels.yaml,autofix.yaml) gate theirmetadatajob 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 useMANUAL_VERSION_BUMP_COMMIT_PREFIXESinstead.
- repomatic.git_ops.GIT_LOG_FORMAT = '%H%x00%B'¶
git logpretty-format placeholders for a single commit: full SHA, then aNUL, then the raw body.Paired with
git log -z(which terminates each commit’s output with aNUL), this frames the stream as alternating(hash, message)tokens. Commit messages may contain newlines but neverNULbytes, so splitting onNULrecovers the fields unambiguously even for multi-line messages.
- class repomatic.git_ops.Commit(hash: str, msg: str)[source]¶
Bases:
NamedTupleA 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
gitCLI feeds these two fields directly.Create new instance of Commit(hash, msg)
- 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:
- repomatic.git_ops.list_commits(start, end)[source]¶
Return the commits in the
start..endrange, 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.
- repomatic.git_ops.commit_exists(ref)[source]¶
Return
Trueif ref resolves to a commit object present locally.- Return type:
- repomatic.git_ops.count_commits(ref='HEAD')[source]¶
Return the number of commits reachable from ref.
- Return type:
- repomatic.git_ops.current_branch()[source]¶
Return the checked-out branch name, or
NonewhenHEADis detached.
- repomatic.git_ops.restore_paths(ref, paths)[source]¶
Overwrite paths in the working tree with the content they hold at ref.
HEADstays 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 agit checkoutof 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:
- Return type:
- Returns:
The paths actually restored, as repository-relative pathspecs.
- repomatic.git_ops.stash_pop()[source]¶
Restore the most recently stashed local changes.
- Return type:
- repomatic.git_ops.stash_count()[source]¶
Return the number of entries on the stash reflog.
- Return type:
- 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:
- 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:
- 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:
- repomatic.git_ops.count_commits_between(start, end)[source]¶
Return the number of commits in the
start..endrange.Follows git range semantics: start is excluded, end is included.
- Return type:
- repomatic.git_ops.is_ancestor(maybe_ancestor, ref)[source]¶
Return whether maybe_ancestor is reachable from ref.
- repomatic.git_ops.merge_base(left, right)[source]¶
Return the best common ancestor of two commits, or
Noneif unrelated.Nonealso covers the shallow-clone case described inis_ancestor().
- repomatic.git_ops.rebase_onto(new_base, old_base, branch)[source]¶
Replay
old_base..branchon top of new_base, keeping the replayed side.--strategy-option=theirsresolves 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:
- Returns:
Trueon success. On conflict the rebase is aborted andFalsereturned, 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
HEADand 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:
- 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
finallythat 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:
- 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 addexits 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.
- 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. Mirrorscommit_and_push_files(), which does the same for the direct-to-default-branch case.- Return type:
- 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/checkoutconfigures a single-branch fetch refspec, under which a baregit fetch origin {branch}updatesFETCH_HEADbut leavesrefs/remotes/{remote}/{branch}absent. Writing the remote-tracking ref is what later lets a push take a--force-with-leaseon it.
- 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-leaseguard: the push is refused when the branch moved in between, rather than silently discarding the other writer’s commit. PassNoneto 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:
- repomatic.git_ops.delete_remote_branch(branch, remote='origin')[source]¶
Delete branch from remote, tolerating a branch already gone.
- Return type:
- 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:
- repomatic.git_ops.get_repo_slug_from_remote(remote='origin')[source]¶
Extract the
owner/reposlug from a git remote URL.Parses both HTTPS and SSH GitHub remote formats. Returns
Noneif the remote is not set, not a GitHub URL, or git is unavailable.
- repomatic.git_ops.get_latest_tag_version()[source]¶
Returns the latest release version from Git tags.
Looks for tags matching the pattern
vX.Y.Zand returns the highest version. ReturnsNoneif 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.Zand 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.
- repomatic.git_ops.get_all_version_tags()[source]¶
Get all version tags and their dates.
Runs a single
git tagcommand to list all tags matching thevX.Y.Zpattern and extracts their dates.
- repomatic.git_ops.create_tag(tag, commit=None)[source]¶
Create a local Git tag.
- Parameters:
- Raises:
subprocess.CalledProcessError – If tag creation fails.
- Return type:
- repomatic.git_ops.push_tag(tag, remote='origin')[source]¶
Push a Git tag to a remote repository.
- Parameters:
- Raises:
subprocess.CalledProcessError – If push fails.
- Return type:
- 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_NAMEvia per-command-cconfig, 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 detachedHEAD: the push targetsHEAD:{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:
- Returns:
Truewhen a commit was pushed,Falsewhen there was nothing to commit.- Raises:
RuntimeError – When the rebase hits a conflict (the local change overlaps a concurrent push) or every push attempt is rejected.
subprocess.CalledProcessError – When a git command fails outright.
- 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_existingis True, it returns False without failing. This allows safe re-runs of workflows that were interrupted after tag creation but before other steps.- Parameters:
- Return type:
- Returns:
True if the tag was created, False if it already existed.
- Raises:
ValueError – If tag exists and skip_existing is False.
subprocess.CalledProcessError – If Git operations fail.