Metadata-Version: 2.5
Name: gheasy
Version: 0.0.13
Summary: github made easy
Project-URL: Repository, https://github.com/vedicreader/gheasy
Project-URL: Documentation, https://vedicreader.github.io/gheasy/
Author-email: Karthik <karthik.rajgopal@hotmail.com>
License: Apache-2.0
License-File: LICENSE
Keywords: nbdev
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.11
Requires-Dist: cyclopts>=0.23
Requires-Dist: ghapi>=1.0.13
Requires-Dist: pyyaml>=6.0
Requires-Dist: ruamel-yaml>=0.18
Requires-Dist: tomlkit>=0.14.0
Description-Content-Type: text/markdown

# gheasy


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

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

``` bash
pip install gheasy
```

## Generate a CI workflow

For a Python project that uses uv, [`uv_ci`](https://vedicreader.github.io/gheasy/workflow.html#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.

``` python
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`](https://vedicreader.github.io/gheasy/repo.html#gitrepo) wraps one local checkout. [`GitRepo.at`](https://vedicreader.github.io/gheasy/repo.html#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.

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

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

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

``` python
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`](https://vedicreader.github.io/gheasy/workflow.html#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.

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

``` python
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`](https://vedicreader.github.io/gheasy/core.html#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`](https://vedicreader.github.io/gheasy/core.html#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.

``` python
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`](https://vedicreader.github.io/gheasy/core.html#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.

``` python
# 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](https://github.com/Karthik777/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.

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