Metadata-Version: 2.4
Name: vcti-git
Version: 1.2.0
Summary: Git repository operations for VCollab applications
Author: Visual Collaboration Technologies Inc.
Requires-Python: <3.15,>=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Provides-Extra: lint
Requires-Dist: ruff; extra == "lint"
Provides-Extra: typecheck
Requires-Dist: mypy; extra == "typecheck"
Provides-Extra: gitpython
Requires-Dist: gitpython; extra == "gitpython"
Provides-Extra: pygit2
Requires-Dist: pygit2; extra == "pygit2"
Provides-Extra: dulwich
Requires-Dist: dulwich; extra == "dulwich"
Provides-Extra: docs
Requires-Dist: sphinx>=8; extra == "docs"
Requires-Dist: myst-parser>=4; extra == "docs"
Requires-Dist: furo; extra == "docs"
Dynamic: license-file

# vcti-git

## Overview

VCollab applications often need to interact with Git repositories —
cloning template repositories, managing remotes, fetching updates,
and pushing changes. The `vcti-git` package provides a single
`Repository` class with a small, focused API for these operations.

The class is a thin façade over a configurable backend. Three backends are
supported:

- **[pygit2](https://pypi.org/project/pygit2/)** — libgit2 bindings via
  a binary wheel. Fastest of the three, no `git` binary required.
  Suitable for slim container images.
- **[GitPython](https://gitpython.readthedocs.io/)** — pure-Python but
  shells out to the system `git` binary for many operations. Easy to
  develop with on any machine that already has Git installed.
- **[dulwich](https://pypi.org/project/dulwich/)** — pure-Python
  implementation of git. No native dependencies, no `git` binary
  required. Slower than the other two but maximally portable
  (useful for AWS Lambda layers, Alpine images without build
  toolchains, etc.).

Backend-specific exceptions are wrapped in a `GitError` hierarchy so
callers do not depend on the underlying library.

**All backends are optional dependencies.** Install at least one extra:

```bash
pip install 'vcti-git[pygit2]'       # pygit2 backend (recommended)
pip install 'vcti-git[gitpython]'    # GitPython backend
pip install 'vcti-git[dulwich]'      # dulwich backend
pip install 'vcti-git[pygit2,gitpython,dulwich]'  # all three
```

If no backend is installed, constructing a `Repository` raises
`GitError` with the install instructions. If `backend` is not passed
explicitly, the constructor auto-detects in preference order:
`pygit2` → `gitpython` → `dulwich`.

> **Status:** This package is not yet published to PyPI. See
> [STATUS.md](STATUS.md) for current status, alternatives considered
> (GitPython, pygit2, dulwich), and why we have not released yet.

### When to use this package

Use `vcti-git` when your application needs to:

- Clone repositories from remote URLs
- Manage multiple remotes (add, remove, list)
- Fetch updates or push changes programmatically
- Pull (fetch + fast-forward only) to update a local branch
- Commit local changes
- Validate whether a directory is a Git repository

### When NOT to use this package

This is a focused wrapper, not a full git client. Drop down to the
underlying library (pygit2, GitPython, or dulwich) directly if you need
any of:

- Branch management (create, switch, list, delete branches)
- Working tree inspection (`git status`, `git diff`, dirty checks)
- Commit history iteration (`git log`)
- Non-fast-forward `git pull`, `git merge`, `git rebase`, `git checkout` (the `pull()` we provide is FF-only)
- Tag creation, cherry-picking, stashing, submodule handling
- Authentication callbacks for private remotes (none of the backends'
  auth APIs are exposed by this wrapper yet)

Repository selection is also single-threaded by design — see
[Thread safety](#thread-safety) below.

---

## Installation

A backend extra must be specified, otherwise `vcti-git` installs with no
git library and `Repository(...)` will raise at construction.

```bash
# pygit2 backend (latest main) — recommended
pip install "vcti-git[pygit2] @ git+https://github.com/vcollab/vcti-python-git.git"

# GitPython backend (latest main)
pip install "vcti-git[gitpython] @ git+https://github.com/vcollab/vcti-python-git.git"

# dulwich backend (latest main)
pip install "vcti-git[dulwich] @ git+https://github.com/vcollab/vcti-python-git.git"

# All three, pinned to a version
pip install "vcti-git[pygit2,gitpython,dulwich] @ git+https://github.com/vcollab/vcti-python-git.git@v1.2.0"
```

### From a GitHub Release

Download the wheel from the
[Releases](https://github.com/vcollab/vcti-python-git/releases)
page and install with the chosen extra:

```bash
pip install "vcti_git-1.2.0-py3-none-any.whl[pygit2]"
```

### In `requirements.txt`

```
vcti-git[pygit2] @ git+https://github.com/vcollab/vcti-python-git.git@v1.2.0
```

### In `pyproject.toml` dependencies

```toml
dependencies = [
    "vcti-git[pygit2] @ git+https://github.com/vcollab/vcti-python-git.git@v1.2.0",
]
```

---

## Quick Start

### Check if a directory is a Git repository

```python
from vcti.git import Repository

repo = Repository("/path/to/repo")
if repo.is_valid():
    print("Valid Git repository")
```

### Clone a remote repository

```python
from vcti.git import Repository

repo = Repository("/tmp/my-clone")
repo.clone("https://github.com/user/repo.git")
print(f"Cloned to: {repo.path}")
```

### Select a backend

```python
from vcti.git import Repository

# Auto-detect in preference order: pygit2 → gitpython → dulwich.
repo = Repository("/path/to/repo")

# Explicit selection.
repo = Repository("/path/to/repo", backend="pygit2")
repo = Repository("/path/to/repo", backend="gitpython")
repo = Repository("/path/to/repo", backend="dulwich")
```

### Manage remotes

```python
from vcti.git import Repository

repo = Repository("/path/to/repo")

# List all remotes
remotes = repo.list_remotes()
# {'origin': 'https://github.com/user/repo.git'}

# Add a new remote (fetches by default)
repo.add_remote("upstream", "https://github.com/org/repo.git")

# Add without fetching
repo.add_remote("backup", "https://backup.example.com/repo.git", fetch=False)

# Remove a remote
repo.remove_remote("backup")
```

### Fetch and push

```python
from vcti.git import Repository

repo = Repository("/path/to/repo")

# Fetch from origin (default)
repo.fetch()

# Fetch from upstream with options
repo.fetch("upstream", prune=True, tags=True)

# Pull (fetch + fast-forward) the current branch from origin.
# Raises GitError if the branch has diverged from the remote —
# pull never creates a merge commit.
repo.pull()

# Push the current branch to origin (auto-detects from HEAD).
# Raises GitError if HEAD is detached or unborn — pass an explicit
# branch name in that case.
repo.push()

# Push a specific branch with force
repo.push("origin", "feature-branch", force=True)
```

### Commit changes

```python
from vcti.git import Repository

repo = Repository("/path/to/repo")

# Commit whatever is already staged in the index
repo.commit("Update configuration")

# Stage all tracked + untracked changes, then commit
repo.commit("Snapshot working tree", add_all=True)
```

### Handle errors

Every exception raised by `Repository` derives from `GitError`. Catch
the specific subclass you care about, or the base class for a blanket
handler:

```python
from vcti.git import GitError, RemoteError, Repository, RepositoryError

repo = Repository("/path/to/repo")

try:
    repo.fetch("origin")
except RemoteError as e:
    # Clone/fetch/push or remote-config failed (often network/auth)
    print(f"Remote operation failed: {e}")
except RepositoryError as e:
    # Path isn't a valid repo, or unsafe to overwrite
    print(f"Repository state invalid: {e}")
except GitError as e:
    # Anything else (commit failure, backend not installed, etc.)
    print(f"Other git failure: {e}")
```

Error messages are prefixed with the backend tag — `[pygit2]`,
`[gitpython]`, or `[dulwich]` — so logs from mixed deployments stay
unambiguous.

### Release resources

`Repository` holds a cached handle to the underlying library's
repository object. Use it as a context manager to release that handle
deterministically:

```python
with Repository("/path/to/repo") as repo:
    repo.commit("done", add_all=True)
# handle released here
```

`repo.close()` is also exposed if you can't use a `with` block. After
`close()`, the instance must not be used again. Calling `close()` more
than once is safe.

### Select a backend at deployment time

In addition to the `backend=` constructor argument, the
`VCTI_GIT_BACKEND` environment variable selects the backend without
touching code. Useful for switching between backends per environment
(e.g. gitpython locally where `git` is installed, pygit2 in containers,
dulwich in pure-Python serverless layers):

```bash
VCTI_GIT_BACKEND=pygit2 python my-app.py
```

Valid values: `pygit2`, `gitpython`, `dulwich`. The constructor
argument, if passed, takes precedence over the env var.

---

## Public API

| Member | Purpose |
|--------|---------|
| `Repository(path, *, backend=None)` | Construct a repository handle. `backend=None` auto-detects (pygit2, then gitpython, then dulwich). |
| `.path` | The resolved local path |
| `.backend_name` | The name of the active backend |
| `.is_valid()` | Check if path contains a valid Git repo |
| `.clone(url, *, overwrite=False)` | Clone a remote repo to the local path |
| `.commit(message="Update", *, add_all=False)` | Commit staged changes; optionally stage everything first |
| `.list_remotes()` | List all remotes as `{name: url}` |
| `.add_remote(name, url, *, fetch=True)` | Add a new remote |
| `.remove_remote(name)` | Remove an existing remote |
| `.fetch(remote="origin", *, prune=False, tags=False)` | Fetch from a remote |
| `.pull(remote="origin", branch=None)` | Fetch and fast-forward the local branch. Raises if diverged. |
| `.push(remote="origin", branch=None, *, force=False)` | Push to a remote (defaults to current branch) |
| `GitError` | Base class for all errors raised by `Repository` |
| `RepositoryError` | Invalid repository state or unsafe overwrite |
| `RemoteError` | Remote operation failed (clone/fetch/push/config) |

---

## Troubleshooting

All errors raised by this package include a backend tag (`[pygit2]`,
`[gitpython]`, or `[dulwich]`) and a descriptive message. Common ones
and what they mean:

| Message fragment | Cause | Fix |
|---|---|---|
| `No git backend is installed` | The package was installed without an extra, or the chosen extra is missing. | `pip install 'vcti-git[pygit2]'` (or `[gitpython]` / `[dulwich]`). |
| `Backend 'X' requires the 'X' extra` | `backend="X"` was passed but `X` is not importable. | Install the matching extra. |
| `Invalid VCTI_GIT_BACKEND=...` | The env var was set to something other than `pygit2` / `gitpython` / `dulwich`. | Unset or correct the variable. |
| `Path already exists` | `clone()` was called on a non-empty target without `overwrite=True`. | Choose a fresh path or pass `overwrite=True`. |
| `Refusing to overwrite <path>: directory is non-empty and not a git repository` | The safety guard: `overwrite=True` refuses to delete a directory that isn't empty and isn't already a git repo (prevents typo-induced data loss). | Verify the path is correct; delete it manually if you really mean it. |
| `Not a valid Git repository: <path>` | An operation that needs an existing repo was called on a path that isn't one. | Initialize or clone first; check the path. |
| `Commit failed: user.name and user.email must be set in git config` (pygit2) | The pygit2 backend cannot construct a signature. | `git config user.name "..."` and `git config user.email "..."` (locally or globally). The gitpython and dulwich backends fall back to OS defaults and do not raise. |
| `Cannot push: HEAD is detached, no current branch to push` (gitpython) / `Cannot push: HEAD is detached or unborn` (pygit2, dulwich) | `push()` was called with no `branch=` while `HEAD` does not point at a branch (detached) or no commits yet (unborn). | Pass `branch=` explicitly: `repo.push("origin", "main")`. |
| `Cannot pull: HEAD is detached, no current branch` / `Cannot pull: HEAD is detached or unborn` | Same situation for `pull()`. | Pass `branch=` explicitly. |
| `Cannot fast-forward '<branch>' from '<remote>': local branch has diverged.` | `pull()` is fast-forward only. The local branch has commits the remote doesn't (or both have moved independently). | Resolve manually using the underlying library: e.g. `git pull --rebase`, `git merge`, or hard-reset to remote. The wrapper deliberately won't do this for you. |
| `Remote 'X' does not exist. Available remotes: [...]` | `fetch` / `push` / `remove_remote` was given a remote name that isn't configured. | Use the actual remote name (the error lists what's available) or `repo.add_remote("X", "...")` first. |
| `Push to '<remote>/<branch>' rejected: ...` | The remote rejected the push (non-fast-forward, server hook, etc.). | Pull and merge first, or pass `force=True` if you intentionally want to overwrite the remote. |
| `Clone failed: ...` | The clone failed (network error, invalid URL, authentication). | The wrapper does not handle authentication; private remotes require credentials configured at the OS / git-config level. |

---

## Thread safety

A `Repository` instance is **not** thread-safe. Each instance lazily
caches an underlying library `Repo` / `pygit2.Repository` object and
the cache itself is unguarded. Use one of:

- A separate `Repository` instance per thread.
- An external lock around any shared instance.

There is no goal to make `Repository` itself thread-safe — the
underlying libraries (GitPython, libgit2) have their own constraints
that would leak through any wrapper-level locking.

---

## Documentation

- [Design](docs/design.md) — Why wrap Git libraries, design decisions, backends
- [Source Guide](docs/source-guide.md) — File structure, dependency map, execution flows
- [API Reference](docs/api.md) — Autodoc for all modules
