Metadata-Version: 2.4
Name: fastpluggy-git-tools
Version: 0.2.1
Summary: Small stdlib-only managed-clone GitOps client (clone/branch/commit/push) — not a FastPluggy plugin, just a library.
Author: FastPluggy Team
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Provides-Extra: tests
Requires-Dist: pytest>=7.0; extra == "tests"
Requires-Dist: pytest-cov>=4.0; extra == "tests"

# git_tools

[![pipeline status](https://gitlab.ggcorp.fr/open/fastpluggy/private_plugins/git_tools/badges/main/pipeline.svg)](https://gitlab.ggcorp.fr/open/fastpluggy/private_plugins/git_tools/-/pipelines)
[![version](https://img.shields.io/badge/version-0.2.1-blue)](https://gitlab.ggcorp.fr/open/fastpluggy/private_plugins/git_tools/-/releases)

A small, **stdlib-only** managed-clone GitOps client — clone / branch / commit / push for a long-lived
working clone (typically on `/data`). **Not a FastPluggy plugin**, just a library you `pip install` and
import.

Extracted and generalized from the proven git logic in `fp-deployer/apps_repo.py` (the careful bits:
`pull --rebase --autostash`, no-change short-circuit, **conflict-marker refusal**, identity via `-c`,
**never force-push**), adding **clone-if-missing** and **feature-branch** support for content-repo callers
like the brain app committing KB captures.

## Why not GitPython / the GitLab API
- **Subprocess git, no GitPython** → consumers stay dependency-light (and it matches `fp-deployer` /
  `ecosystem_status`, which deliberately avoid the optional dep). It also lets you run the repo's *own*
  scripts against the clone (e.g. the KB's `status.py` / `kb_checks.py`).
- The GitLab Commits API is the clone-free alternative (what `brain/kb_capture.py` uses today); it's fine
  for simple text commits but blind to repo state. `git_tools` is the clone-based path.

## Usage

```python
from git_tools import GitRepo

repo = GitRepo(
    path="/data/kb/fp-tmp-personal",
    remote_url="https://oauth2:<token>@gitlab.ggcorp.fr/JeJe/fp-tmp-personal.git",  # or git@… + key_path
    branch="main",
)

# One call: clone-if-needed → feature branch off main → write → commit → push (idempotent).
res = repo.commit_files(
    {"_raw/brain/2026-06-12_x_42/content.md": "...", "_raw/brain/2026-06-12_x_42/meta.yml": "source: brain\n"},
    "brain capture: x (share 42)",
    branch="brain-app", base="main",
)
# -> {"sha": "abc1234", "committed": True, "branch": "brain-app"}
```

Auth: embed a token in `remote_url` for HTTPS (`https://oauth2:<token>@host/repo.git`), or pass
`key_path=` (+ optional `known_hosts=`) for an SSH remote. Serialize concurrent writers on one shared
clone with `with repo.lock(): ...`.

## API
`GitRepo(path, remote_url, branch, key_path, known_hosts, author_name, author_email, timeout)` with:
`ensure_clone()` · `clone_or_refresh()` · `pull_rebase()` · `prepare_branch(branch, base)` ·
`write_files(files)` · `remove_files(paths)` · `move(src, dest)` · `commit(paths, message, *, stage=True)` ·
`push(branch)` · `commit_files(...)` (high-level) · `status()` · `lock()`. Working-tree inspection:
`diff(paths=None, *, staged=None)` · `changed_files(paths=None) -> list[FileChange]`. Failures raise
`GitError` / `Conflict` (both subclass `GitToolsError`).

`changed_files` parses `git status --porcelain` into `FileChange(path, status, staged)` records — for a
consumer that works on an *already-checked-out* repo (status/diff/move, no push), e.g. ecosystem_status'
`wip-ia-code/` flow. (Distinct from `status()`, which probes clone liveness / HEAD.)

```python
for c in repo.changed_files(["wip-ia-code/"]):   # status --porcelain, scoped
    print(c.path, c.status, "staged" if c.staged else "unstaged")
print(repo.diff(["wip-ia-code/"]))               # combined worktree + cached diff
repo.move("wip-ia-code/a.md", "wip-ia-code/plans/a.md")  # git mv
```

Delete a subtree symmetrically with the write path — `git rm` stages the removal, so commit with
`stage=False` (re-adding a now-absent path would fail):

```python
repo.pull_rebase()
repo.remove_files(["myapp"])             # git rm -r -- myapp
res = repo.commit(["myapp"], "drop myapp", stage=False)
if res["committed"]:
    repo.push()
```
