Metadata-Version: 2.4
Name: fastpluggy-git-tools
Version: 0.6.2
Summary: Small stdlib-only managed-clone GitOps client (clone/branch/commit/push + GitopsRepo sync/recovery adapter) — 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)
[![coverage report](https://gitlab.ggcorp.fr/open/fastpluggy/private_plugins/git_tools/badges/main/coverage.svg)](https://gitlab.ggcorp.fr/open/fastpluggy/private_plugins/git_tools/-/commits/main)
[![version](https://img.shields.io/badge/version-0.6.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)` · `delete_branch(name, *, checkout_base=None)` (local-branch prune, idempotent — 0.5.0) ·
`commit_files(...)` (high-level) · `status()` · `lock()`. Working-tree inspection:
`diff(paths=None, *, staged=None)` · `changed_files(paths=None) -> list[FileChange]`. Read surface
(0.4.0): `refresh_readonly(branch=None)` · `list_tree(subdir=None, *, ref=None)` ·
`read_file(relpath, *, ref=None)` · `read_bytes(relpath, *, ref=None)` ·
`grep(pattern, *, paths=None, ref=None, ignore_case=False, fixed=False, max_count=None) -> list[GrepMatch]`;
plus the `skip_lfs_smudge` ctor flag. On-demand LFS materialization (0.4.1): `materialize_lfs(relpath)` ·
module-level `is_lfs_pointer(data: bytes) -> bool`. 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()
```

## GitopsRepo — managed-clone sync/recovery adapter (0.6.0)

`GitopsRepo` sits one level above `GitRepo`: a long-lived, container-managed working clone (typically on
`/data`) with a **best-effort liveness surface + self-healing sync** — the shape a control plane needs to
keep a GitOps checkout fresh and to recover a wedged clone from a UI. Promoted from fp-deployer's private
`gitops_repo.py` (fp-deployer#23) so **any** plugin can reuse it (git_tools#7): the deployer's `AppsRepo` /
`InfraRepo` are thin subclasses, and `ecosystem_status` uses it to provision + keep-fresh the `fp-ecosystem`
cockpit checkout.

```python
from git_tools import GitopsRepo

repo = GitopsRepo(
    path="/data/fp-ecosystem",
    remote_url="git@gitlab.ggcorp.fr:internal/fastpluggy-ecosystem.git",
    branch="main",
    key_path="/root/.ssh/ecosystem-deploy",       # SSH deploy key (mount ro)
    known_hosts="/root/.ssh/known_hosts",
)

repo.sync()    # clone-if-missing (needs remote_url) else pull --rebase --autostash, self-healing
repo.status()  # {configured, branch, path, key_path, key_present, clone_present, clone_head, state, error}
repo.reset()   # DESTRUCTIVE: hard-reset to origin/<branch> — the unstick path (disposable mirror only)
repo.repo_diff()  # working-tree diff — 'view before reset'
```

`GitopsRepo(path, remote_url="", branch="main", key_path="", known_hosts="", timeout=60)`. It's framework-
agnostic (stdlib only — **no** FastPluggy / tasks_worker); a plugin wires its own thin scheduled task
(calling `sync()`) and status card around these plain-dict methods. Subclass and override `_git_kwargs()` to
feed the composed `GitRepo` extras — e.g. `{"author_name": …, "author_email": …}` for a read-write clone, or
`{"skip_lfs_smudge": True}` for a read-only text mirror — plus any write ops of your own. Set `_log_name` for
legible per-consumer logs.

`sync()` clones when `path` is empty (**requires `remote_url`**) else pulls with `recover=True`, so a clone
left conflicted by a prior failed pull self-heals instead of erroring — safe because a managed mirror is
reproducible from origin. `status()` never raises (best-effort probe for a card). `reset()` is the explicit,
destructive unstick.

## Read surface (0.4.0) — browse / view / search a clone

For a clone used as a **read-only surface** (browse, view, index/search the content — e.g. the brain
reading the KB), keep it separate from any write-clone and sync it destructively:

```python
kb = GitRepo(
    path="/data/kb-ro/fp-tmp-personal",
    remote_url="https://oauth2:<token>@gitlab.ggcorp.fr/JeJe/fp-tmp-personal.git",
    branch="main",
    skip_lfs_smudge=True,   # clone LFS *pointers*, never the blobs (and dodge the smudge filter)
)
kb.refresh_readonly()                       # clone-if-missing, then fetch + reset --hard origin/main

kb.list_tree("ai-ml")                       # tracked paths under a subtree (or ref=… for a commit/branch)
kb.read_file("ai-ml/audio-tts-stt.md")      # text (ref=… → git show <ref>:<path>)
kb.read_bytes("img/logo.png")               # binary — for serving assets (LFS path -> pointer text!)
kb.materialize_lfs("img/logo.png")          # real bytes: targeted `git lfs pull --include=`, no-op if not a pointer
for m in kb.grep("pgvector", ignore_case=True):
    print(m.path, m.line, m.text)           # -> list[GrepMatch(path, line, text)]
```

With `skip_lfs_smudge=True`, `read_bytes()` on an LFS-tracked path returns the **pointer text**, not the
real blob — that's the point of skipping smudge on clone (don't pull every binary just to browse
markdown). When a caller actually needs one asset's bytes (e.g. serving an `<img>` in a rendered doc),
call `materialize_lfs(relpath)` instead: it fetches just that one object (`git lfs pull --include=`),
leaving every other LFS path in the clone untouched as a pointer. Safe to call unconditionally — it's a
no-op (returns the bytes as-is) for a non-LFS path or one that's already real.

`refresh_readonly()` is **destructive** (hard-reset) — only for a clone that holds no local work; in
return it needs no rebase, so it's safe on LFS-heavy repos. The read helpers accept `ref=` to read a
commit/branch directly, so they're correct even if another caller has the worktree on a different branch.
