gheasy
gheasy has four modules, and most projects only need one or two of them.
gheasy.workflow writes GitHub Actions workflows. A workflow is a YAML file under .github/workflows/ that tells GitHub which jobs to run on a push, a pull request or a release. You describe it with chained Python calls and gheasy writes the YAML, so you never have to get the indentation of a hand-written file right.
gheasy.core keeps a project’s CI settings in one file, .gheasy/config.json, and generates the workflow, git hooks, Git LFS rules and GitHub secrets from it. It also installs the gheasy command line tool.
gheasy.repo reads and changes a local Git checkout. Before any change that could lose work, such as a merge, a rebase or a reset, it records a safepoint: the commit the branch pointed at and a snapshot of your uncommitted changes. A single call puts everything back.
gheasy.repos works across many checkouts at once, for a folder that holds several repositories you work on together.
Install
pip install gheasy
Generate a CI workflow
For a Python project that uses uv, uv_ci gives you a complete CI workflow in one call. It runs on every push and pull request to main, installs dependencies with uv sync --frozen, runs ruff, and then runs pytest. Passing lint_cmd=None, as here, leaves out the lint job. to_yaml returns the text, and save('.github/workflows/ci.yml') writes it to the place GitHub looks for it.
from gheasy.workflow import uv_ci
print(uv_ci("ci", lint_cmd=None).to_yaml())
name: ci
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --frozen
- name: Test
run: uv run pytest
Inspect a local repository
GitRepo wraps one local checkout. GitRepo.at takes the repository root or any file or folder inside it, and finds the root itself. info returns everything an editor’s Git panel shows, in one dictionary: the current branch, how many commits it is ahead of and behind its upstream (the remote branch it tracks), the local and remote branches, remotes, uncommitted changes, tags, stashes, any merge or rebase in progress, whether the repository’s content filters can run, and recent safepoints. When a one-line summary is enough, for a status bar or a prompt, brief is cheaper.
from gheasy.repo import GitRepo
repo = GitRepo.at('.')
status = repo.info()
status['branch'], status['clean'], status['ahead'], status['behind']
('claude/ramabana-branching-review-uqj6v2', False, 0, 0)
Preview before changing history
A merge can stop on conflicts and a rebase rewrites your commits, so it helps to know what will happen before you run either. divergence compares the current branch with its upstream and tries a fast-forward, a merge, a rebase and a reset without performing any of them. It reports which are possible, which would conflict, and which it recommends. fetch=True fetches from the remote first, so the comparison is against the latest upstream. merge_preview and rebase_preview answer the same question for one branch you name.
None of these change your branches, the index or the files on disk. They use git merge-tree, which works out the result of a merge without checking anything out.
preview = repo.divergence(fetch=True)
preview['relation'], preview['recommended'], preview['options']
repo.rebase_preview('origin/main')
{'current': 'claude/ramabana-branching-review-uqj6v2',
'onto': 'origin/main',
'clean': False,
'dirty': ['README.md',
'gheasy/core.py',
'gheasy/repo.py',
'gheasy/workflow.py',
'nbs/00_core.ipynb',
'nbs/02_repo.ipynb',
'nbs/index.ipynb',
'uv.lock'],
'untracked': 8,
'merge_base': '80bb02422f0bbe2ec6dcef2e74c641b19879ac16',
'commits': [{'oid': 'bc8bbb72c1003c9adcca87f3070cb8bdb8efb000',
'short': 'bc8bbb7',
'parents': ['ea046eeb5df51e5f0fac69276a1985bcbfa4f23e'],
'author': 'Claude',
'email': 'noreply@anthropic.com',
'timestamp': 1787690153,
'decoration': 'HEAD -> claude/ramabana-branching-review-uqj6v2, origin/claude/ramabana-branching-review-uqj6v2, origin/claude/ramabana-branching-notebook-nbnzye',
'signature': 'N',
'subject': 'Release 0.0.8'},
{'oid': 'ea046eeb5df51e5f0fac69276a1985bcbfa4f23e',
'short': 'ea046ee',
'parents': ['80bb02422f0bbe2ec6dcef2e74c641b19879ac16'],
'author': 'Claude',
'email': 'noreply@anthropic.com',
'timestamp': 1787690023,
'decoration': '',
'signature': 'N',
'subject': 'Add gheasy.repo: git repository operations as a literate notebook'}],
'conflicts': [],
'conflict_likely': False,
'already_based': True,
'review': {'base': 'origin/main',
'head': 'claude/ramabana-branching-review-uqj6v2',
'base_oid': '80bb02422f0bbe2ec6dcef2e74c641b19879ac16',
'head_oid': 'bc8bbb72c1003c9adcca87f3070cb8bdb8efb000',
'diff_base_oid': '80bb02422f0bbe2ec6dcef2e74c641b19879ac16',
'merge_base': '80bb02422f0bbe2ec6dcef2e74c641b19879ac16',
'mode': 'review',
'base_only': 0,
'head_only': 2,
'files': [{'path': 'CHANGELOG.md',
'old_path': '',
'status': 'M',
'similarity': 0,
'additions': 8,
'deletions': 0,
'binary': False},
{'path': 'gheasy/__init__.py',
'old_path': '',
'status': 'M',
'similarity': 0,
'additions': 1,
'deletions': 1,
'binary': False},
{'path': 'gheasy/_modidx.py',
'old_path': '',
'status': 'M',
'similarity': 0,
'additions': 152,
'deletions': 0,
'binary': False},
{'path': 'gheasy/core.py',
'old_path': '',
'status': 'M',
'similarity': 0,
'additions': 9,
'deletions': 7,
'binary': False},
{'path': 'gheasy/repo.py',
'old_path': '',
'status': 'A',
'similarity': 0,
'additions': 1659,
'deletions': 0,
'binary': False},
{'path': 'nbs/00_core.ipynb',
'old_path': '',
'status': 'M',
'similarity': 0,
'additions': 9,
'deletions': 7,
'binary': False},
{'path': 'nbs/02_repo.ipynb',
'old_path': '',
'status': 'A',
'similarity': 0,
'additions': 4298,
'deletions': 0,
'binary': False},
{'path': 'uv.lock',
'old_path': '',
'status': 'M',
'similarity': 0,
'additions': 1931,
'deletions': 2551,
'binary': False}],
'snapshot_alternative': None,
'commits': [{'oid': 'bc8bbb72c1003c9adcca87f3070cb8bdb8efb000',
'short': 'bc8bbb7',
'parents': ['ea046eeb5df51e5f0fac69276a1985bcbfa4f23e'],
'author': 'Claude',
'email': 'noreply@anthropic.com',
'timestamp': 1787690153,
'decoration': 'HEAD -> claude/ramabana-branching-review-uqj6v2, origin/claude/ramabana-branching-review-uqj6v2, origin/claude/ramabana-branching-notebook-nbnzye',
'signature': 'N',
'subject': 'Release 0.0.8'},
{'oid': 'ea046eeb5df51e5f0fac69276a1985bcbfa4f23e',
'short': 'ea046ee',
'parents': ['80bb02422f0bbe2ec6dcef2e74c641b19879ac16'],
'author': 'Claude',
'email': 'noreply@anthropic.com',
'timestamp': 1787690023,
'decoration': '',
'signature': 'N',
'subject': 'Add gheasy.repo: git repository operations as a literate notebook'}],
'summary': {'files': 8, 'additions': 8067, 'deletions': 2566, 'binary': 0},
'can_apply': False}}
Change the repository with a way back
The methods that can lose work record a safepoint before they run. These are checkout, commit, pull, merge, rebase, cherry_pick, revert, reset, sync, stash_pop and stash_apply. Each returns a dictionary with a one-sentence summary of what happened and an undo token. Passing the token to undo restores the branch, the commit and the uncommitted work as they were.
sync carries out one of the options divergence offered. The usual pattern is to call divergence, read recommended, and pass it to sync, as the last line here does.
result = repo.checkout('feature')
print(result['summary'])
repo.undo(result['undo'])
# Apply the strategy selected after reviewing `divergence`.
repo.sync(preview['recommended'])
Review branches and commits
review(base, head) shows what a branch changed: each file with its added and deleted line counts, the commits, and totals. It compares head with the merge base, the last commit the two branches share, so work that landed on base after the branch split off does not show up as part of the branch. Loading every file’s contents up front would be slow for a large branch, so review_file loads the two versions of one file, and the patch between them, only when you ask. commit_review and commit_file give the same view of a single commit compared with its parent.
review = repo.review('main', 'claude/ramabana-branching-review-uqj6v2')
review['summary'], review['commits']
file_review = repo.review_file('main', 'claude/ramabana-branching-review-uqj6v2', review['files'][0]['path'])
file_review['left'], file_review['right'], file_review['patch']
('# Release notes\n\n<!-- do not remove -->\n\n## 0.0.7\nghapi is async, so sync=True for now\n\n\n\n## 0.0.6\ngheasy new setup python requires to >=3.11\n\n\n\n## 0.0.5\npins python to 3.13, git workflows optional\n\n\n\n## 0.0.4\nnbdev pyproject to hatchling bugfix + cli addition\n\n\n\n## 0.0.3\nskills\n\n\n\n## 0.0.2\ngheasy makes git lfs, worfklows easy\n\n\n\n## 0.0.1\ninitial release\n\n\n',
"# Release notes\n\n<!-- do not remove -->\n\n## 0.0.8\n\nNew `gheasy.repo`: git repository operations, moved here from `ramabana.git`. One gateway that\nserialises every git process per repository, a safepoint before every mutation, and previews that\nrehearse a merge or a rebase without touching the worktree. `gheasy.core`'s own git calls now go\nthrough the same gateway.\n\n\n## 0.0.7\nghapi is async, so sync=True for now\n\n\n\n## 0.0.6\ngheasy new setup python requires to >=3.11\n\n\n\n## 0.0.5\npins python to 3.13, git workflows optional\n\n\n\n## 0.0.4\nnbdev pyproject to hatchling bugfix + cli addition\n\n\n\n## 0.0.3\nskills\n\n\n\n## 0.0.2\ngheasy makes git lfs, worfklows easy\n\n\n\n## 0.0.1\ninitial release\n\n\n",
"diff --git a/CHANGELOG.md b/CHANGELOG.md\nindex 54062f3..eb0aabf 100644\n--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -2,6 +2,14 @@\n \n <!-- do not remove -->\n \n+## 0.0.8\n+\n+New `gheasy.repo`: git repository operations, moved here from `ramabana.git`. One gateway that\n+serialises every git process per repository, a safepoint before every mutation, and previews that\n+rehearse a merge or a rebase without touching the worktree. `gheasy.core`'s own git calls now go\n+through the same gateway.\n+\n+\n ## 0.0.7\n ghapi is async, so sync=True for now\n \n")
Build a library pipeline
For anything uv_ci does not cover, start from Workflow(name), add triggers through .on, and add jobs. gheasy has one-call presets for the common jobs. Here uv_lint_job runs ruff, uv_test_job runs pytest once lint has passed, and uv_pypi_job builds the package and uploads it to PyPI once the tests have passed. build() turns the builder into a document you can print or save.
The publish job only runs when a GitHub release starts the workflow. This example triggers on pushes and pull requests alone, so add .release(types=['published']) to the triggers when you want releases to publish.
from gheasy.workflow import Workflow
wfb = Workflow("ci")
wfb.on.push(branches=["main"]).pull_request()
wfb.uv_lint_job()
wfb.uv_test_job(needs="lint")
wfb.uv_pypi_job(needs="test")
print(wfb.build().to_yaml())
name: ci
on:
push:
branches:
- main
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --frozen
- name: Lint
run: uv run ruff check . && uv run ruff format --check .
test:
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --frozen
- name: Test
run: uv run pytest
publish:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'release'
permissions:
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v5
- name: Build
run: uv build
- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
Build an application pipeline
When no preset fits, build the job step by step. job(name) starts a job, needs makes it wait for another job, and runs_on picks the machine it runs on. A step helper such as checkout or setup_uv adds one step and returns that step, so end_step() takes you back to the job to add the next step, and end_job() takes you back to the workflow. This example tests every push to main and then deploys to Fly.io.
wfb = Workflow("deploy")
wfb.on.push(branches=["main"])
wfb.uv_test_job()
wfb.job("deploy").needs("test").runs_on("ubuntu-latest")\
.checkout().end_step()\
.setup_uv().end_step()\
.step("Deploy to Fly.io").run("fly deploy --remote-only").end_job()
print(wfb.build().to_yaml())
name: deploy
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --frozen
- name: Test
run: uv run pytest
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v5
- name: Deploy to Fly.io
run: fly deploy --remote-only
Configure LFS and secrets
Git LFS keeps large binary files out of the Git history and commits a small pointer file in their place. gh_lfs installs LFS in the repository and tells it which file patterns to handle.
GitHub Actions jobs read their settings from secrets, which GitHub encrypts and hides in logs, and from variables, which are plain text. gh_push_env takes a dict of values and sends each key to one or the other, using the env_schema saved in .gheasy/config.json. A key whose schema default is None becomes a secret, and a key with a string default becomes a variable. Keys missing from the schema are skipped with a warning. It sets them with the gh command line tool, which must be installed and logged in.
from gheasy.core import gh_lfs, gh_secrets_from_file, gh_push_env, GheasyConfig
# 1. Track binary/media files in git-lfs
gh_lfs(['*.mp3', '*.ogg', '*.wav', '*.png', '*.jpg', '*.webp', '*.xml', '*.db'])
# 2. Describe the settings: a None default makes a secret, a string default makes a variable
cfg = GheasyConfig(app='vedicreader', env_schema={
# variables (have sane defaults)
'MODE': 'dev', 'PORT': '5001', 'DOMAIN': 'http://localhost:5001',
'WANT_GOOGLE': 'true', 'WANT_GIT': 'false',
'NEED_BACKUP': 'false', 'RC_TYPE': 's3', 'RC_PROVIDER': 'Cloudflare',
# secrets (no default, so they must be set)
'JWT_SCRT': None, 'RESEND_API_KEY': None,
'GOOGLE_CLI': None, 'GOOGLE_SCRT': None,
'CF_ACCESS_KEY_ID': None, 'CF_SCRT_ACCESS_KEY': None, 'CF_ENDPOINT': None,
})
cfg.save()
# sends each schema key found in the environment: None default -> gh secret set, string default -> gh variable set
import os
gh_push_env(dict(os.environ))
When every value should be a secret, you can skip the schema. gh_secrets_from_file reads the KEY=VALUE lines of a file such as .env, ignores blank lines and # comments, and sets each one as a GitHub secret. dry_run=True prints the gh commands instead of running them, and env='prod' limits the secrets to jobs that deploy to the prod environment.
# Push all KEY=VALUE pairs from .env as GitHub secrets
gh_secrets_from_file('.env')
# With dry-run to preview what would be pushed
gh_secrets_from_file('.env', dry_run=True)
Build a cross-package pipeline
A step can run any shell command, including Python that imports another package. This workflow uses dockeasy to write a Dockerfile, then builds the image and pushes it to GHCR, GitHub’s container registry. The paths filter means a push only starts it when one of the files that generate the Dockerfile has changed. workflow_dispatch adds a button to run it by hand from the repository’s Actions tab, and with_ sets an action’s inputs.
wfb = Workflow("Build caddy-sqlite image")
wfb.on.push(branches=["main"], paths=["dockeasy/proxy.py", "nbs/01_proxy.ipynb"])
wfb.on.workflow_dispatch()
wfb.job("build-and-push").runs_on("ubuntu-latest")\
.permissions(contents="read", packages="write")\
.checkout().end_step()\
.setup_uv().end_step()\
.step("Install deps").run("uv sync").end_step()\
.step("Generate Dockerfile").run(
'uv run python -c "\n'
'from dockeasy.proxy import caddy_sqlite_dockerfile\n'
'caddy_sqlite_dockerfile().save(\'Dockerfile\')\n'
'"'
).end_step()\
.step("Log in to GHCR").uses("docker/login-action@v3")\
.with_(registry="ghcr.io",
username="${{ github.actor }}",
password="${{ secrets.GITHUB_TOKEN }}").end_step()\
.step("Build and push").uses("docker/build-push-action@v5")\
.with_(context=".", push=True,
tags="ghcr.io/vedicreader/caddy-sqlite:latest").end_job()
print(wfb.build().to_yaml())
name: Build caddy-sqlite image
on:
push:
branches:
- main
paths:
- dockeasy/proxy.py
- nbs/01_proxy.ipynb
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v5
- name: Install deps
run: uv sync
- name: Generate Dockerfile
run: "uv run python -c \"\nfrom dockeasy.proxy import caddy_sqlite_dockerfile\ncaddy_sqlite_dockerfile().save('Dockerfile')\n\
\""
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/vedicreader/caddy-sqlite:latest
Metadata
Release files for gheasy 0.0.14
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| gheasy-0.0.14.tar.gz | 63.7 kB | Without provenance | Details for gheasy-0.0.14.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| gheasy-0.0.14-py3-none-any.whl | Python 3 | none | any | Details for gheasy-0.0.14-py3-none-any.whl |
Total release size: 129.7 kB
Release files / gheasy-0.0.14.tar.gz
| Download URL | gheasy-0.0.14.tar.gz |
|---|---|
| Size | 63.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
be62fab11df2540e91f501ce2537025875c80d5cc2afef5e4f836fd8bc681402
|
|
BLAKE2b-256 checksum How to use checksums |
b8e60ca4e4705465bf57230c9337f0f096c4c9c24fcab5de4d805fec5c918b1b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Release files / gheasy-0.0.14-py3-none-any.whl
| Download URL | gheasy-0.0.14-py3-none-any.whl |
|---|---|
| Size | 66.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b1ba36ffaeb1366210b2b30b0d35b7194594fae647a073f56531d26e145ba1e2
|
|
BLAKE2b-256 checksum How to use checksums |
e42cfd8c45d2ab288891ed8e80b2301d6c969e3388a7ee14d00f7e2f323177b5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Security for gheasy 0.0.14
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.