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. You never write or indent YAML by hand.
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 provides 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. One call restores both.
gheasy.repos works across many checkouts at once, for a folder that holds several repositories you work on together.
pip install gheasyFor a Python project that uses uv, uv_ci returns a complete CI workflow. It runs on every push and pull request to main, installs dependencies with uv sync --frozen, runs ruff, and then runs pytest. lint_cmd=None, as here, leaves out the lint job. to_yaml returns the YAML text. save('.github/workflows/ci.yml') writes it to the path where GitHub looks for workflows.
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
GitRepo wraps one local checkout. GitRepo.at takes the repository root or any file or folder inside it, and finds the root. info returns everything an editor’s Git panel shows, in one dictionary. The dictionary holds the current branch, the number of commits the branch is ahead of and behind its upstream (the remote branch it tracks), the local and remote branches, the remotes, uncommitted changes, tags, stashes, any merge or rebase in progress, whether the repository’s content filters can run, and the 12 most recent safepoints. info caches its result for a short time. brief returns one row with the branch, ahead and behind counts, uncommitted counts, the last commit and the commits since the last tag. Use brief for a status bar or a prompt.
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)
A merge can stop on conflicts, and a rebase rewrites your commits. divergence shows what each would do before you run either. It compares the current branch with its upstream and rehearses a fast-forward, a merge, a rebase and a reset without performing any of them. It reports which are available, which would conflict, and which one it recommends. fetch=True fetches from the remote first. merge_preview reports a proposed merge with the branch you name. rebase_preview reports a proposed rebase onto the branch you name.
None of these methods change your branches, the index or the files on disk. They use git merge-tree, which computes 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}}
These methods record a safepoint before they run: 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. undo(token) restores the branch, the commit and the uncommitted work as they were.
sync carries out one of the options divergence offered: fast-forward, merge, rebase or reset. The default is rebase. Call divergence, read recommended, and pass it to sync, as the last line here does. recommended is an empty string when the branch is identical to its upstream or ahead of it. Pass sync a real option in that case, or skip the call.
result = repo.checkout('feature')
print(result['summary'])
repo.undo(result['undo'])
repo.sync(preview['recommended'])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. Commits added to base after the branch split off do not appear as changes of the branch. review does not load file contents. review_file(base, head, path) loads the two versions of one file and the patch between them when you call it. 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")
For anything uv_ci does not cover, start from Workflow(name), add triggers through .on, and add jobs. gheasy has one-call presets for common jobs. uv_lint_job adds a lint job that runs ruff. uv_test_job adds a test job that runs pytest. uv_pypi_job adds a publish job that builds the package and uploads it to PyPI. needs names the job that must pass first. build() turns the builder into a document you can print or save.
The publish job runs only when a GitHub release starts the workflow. This example triggers on pushes and pull requests only. Add .release(types=['published']) to the triggers to publish on release.
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
When no preset fits, build the job step by step. job(name) starts a job. needs makes it wait for another job. runs_on sets the machine it runs on. A step helper such as checkout or setup_uv adds one step and returns that step. end_step() returns to the job so you can add the next step. end_job() returns 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
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 tracks the file patterns you pass. It also saves the patterns to .gheasy/config.json unless you pass persist=False. With no patterns it tracks a default list of model and data file types.
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 as a secret or a variable, as the env_schema in .gheasy/config.json says. A key whose schema default is None becomes a secret. A key with a string default becomes a variable. A key missing from the schema is skipped with a warning. gh_push_env raises FileNotFoundError when .gheasy/config.json does not exist. It sets the values 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
gh_lfs(['*.mp3', '*.ogg', '*.wav', '*.png', '*.jpg', '*.webp', '*.xml', '*.db'])
cfg = GheasyConfig(app='vedicreader', env_schema={
# variables: string 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: None defaults
'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()
import os
gh_push_env(dict(os.environ))When every value is a secret, the schema is not needed. gh_secrets_from_file reads the KEY=VALUE lines of a file such as .env, ignores blank lines and # comments, and sets each value as a GitHub secret. dry_run=True prints the gh commands and does not run them. env='prod' scopes the secrets to the prod environment.
gh_secrets_from_file('.env')
gh_secrets_from_file('.env', dry_run=True)A step can run any shell command, including Python that imports another package. This workflow uses dockeasy to write a Dockerfile. It then builds the image and pushes it to GHCR, GitHub’s container registry. The paths filter starts the workflow on a push only when one of the files that generate the Dockerfile has changed. workflow_dispatch adds a button to run the workflow by hand from the repository’s Actions tab. with_ sets the inputs of an action.
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