Skip to main content

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.13

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for gheasy 0.0.13
File Size Uploaded Provenance Actions
gheasy-0.0.13.tar.gz 63.7 kB Without provenance Details for gheasy-0.0.13.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for gheasy 0.0.13
File Interpreter ABI Platform Actions
gheasy-0.0.13-py3-none-any.whl Python 3 none any Details for gheasy-0.0.13-py3-none-any.whl

Total release size: 129.6 kB

Release files / gheasy-0.0.13.tar.gz

Download URL gheasy-0.0.13.tar.gz
Size 63.7 kB
Tags Source
SHA-256 checksum
How to use checksums
720f3b3aa88a8d6fd8abd1cc5331efde2ba4c360dc006ce8bb85d78200d765fe
BLAKE2b-256 checksum
How to use checksums
62f4c2d4de5b3a8c245f0a83312cbfaa3d680788fad5dc874f14d38789c1523c
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.13-py3-none-any.whl

Download URL gheasy-0.0.13-py3-none-any.whl
Size 65.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5a51884b41faee462988bc88acccdcab75bff20040e1734a1960ff6d89a50881
BLAKE2b-256 checksum
How to use checksums
ec686e1692b6eabd33792041f9ddd447883f14a95c5219845d367b04304f2600
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.13

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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page