Metadata-Version: 2.5
Name: gheasy
Version: 0.0.15
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. 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

``` 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) returns a complete CI workflow. It runs on every push and pull request to [`main`](https://vedicreader.github.io/gheasy/core.html#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.

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

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

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

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.

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

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

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

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

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

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