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

Install

pip install gheasy

Generate a CI workflow

For 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

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

Preview before changing history

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}}

Change the repository with a way back

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 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. 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")

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

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

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 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)

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

Metadata

Release files for gheasy 0.0.17

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.17
File Size Uploaded Provenance Actions
gheasy-0.0.17.tar.gz 64.3 kB Without provenance Details for gheasy-0.0.17.tar.gz

Built distribution (wheel)

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

Total release size: 130.8 kB

Release files / gheasy-0.0.17.tar.gz

Download URL gheasy-0.0.17.tar.gz
Size 64.3 kB
Tags Source
SHA-256 checksum
How to use checksums
bea20b8ca4835cdb40673e074e42d59bd18513c8d10c5494a0d9b7e3868aa9f2
BLAKE2b-256 checksum
How to use checksums
2f55385d7de62d90b7ee18f4de5effd470972c46e455b0a421491f7e1f90f2cf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.1

Release files / gheasy-0.0.17-py3-none-any.whl

Download URL gheasy-0.0.17-py3-none-any.whl
Size 66.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
50c09ab39a3887ec32d767fe813b8bf3da9602a95861a71120d4bda03a8d5239
BLAKE2b-256 checksum
How to use checksums
50a56e1b20b3b5c2e3dcfbb2a0a37a33129d161c473000057ed3320d642e083c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.1

Security for gheasy 0.0.17

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