fastpluggy-git-tools 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastpluggy-git-tools
3
+ Version: 0.2.1
4
+ Summary: Small stdlib-only managed-clone GitOps client (clone/branch/commit/push) — not a FastPluggy plugin, just a library.
5
+ Author: FastPluggy Team
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: tests
10
+ Requires-Dist: pytest>=7.0; extra == "tests"
11
+ Requires-Dist: pytest-cov>=4.0; extra == "tests"
12
+
13
+ # git_tools
14
+
15
+ [![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)
16
+ [![version](https://img.shields.io/badge/version-0.2.1-blue)](https://gitlab.ggcorp.fr/open/fastpluggy/private_plugins/git_tools/-/releases)
17
+
18
+ A small, **stdlib-only** managed-clone GitOps client — clone / branch / commit / push for a long-lived
19
+ working clone (typically on `/data`). **Not a FastPluggy plugin**, just a library you `pip install` and
20
+ import.
21
+
22
+ Extracted and generalized from the proven git logic in `fp-deployer/apps_repo.py` (the careful bits:
23
+ `pull --rebase --autostash`, no-change short-circuit, **conflict-marker refusal**, identity via `-c`,
24
+ **never force-push**), adding **clone-if-missing** and **feature-branch** support for content-repo callers
25
+ like the brain app committing KB captures.
26
+
27
+ ## Why not GitPython / the GitLab API
28
+ - **Subprocess git, no GitPython** → consumers stay dependency-light (and it matches `fp-deployer` /
29
+ `ecosystem_status`, which deliberately avoid the optional dep). It also lets you run the repo's *own*
30
+ scripts against the clone (e.g. the KB's `status.py` / `kb_checks.py`).
31
+ - The GitLab Commits API is the clone-free alternative (what `brain/kb_capture.py` uses today); it's fine
32
+ for simple text commits but blind to repo state. `git_tools` is the clone-based path.
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from git_tools import GitRepo
38
+
39
+ repo = GitRepo(
40
+ path="/data/kb/fp-tmp-personal",
41
+ remote_url="https://oauth2:<token>@gitlab.ggcorp.fr/JeJe/fp-tmp-personal.git", # or git@… + key_path
42
+ branch="main",
43
+ )
44
+
45
+ # One call: clone-if-needed → feature branch off main → write → commit → push (idempotent).
46
+ res = repo.commit_files(
47
+ {"_raw/brain/2026-06-12_x_42/content.md": "...", "_raw/brain/2026-06-12_x_42/meta.yml": "source: brain\n"},
48
+ "brain capture: x (share 42)",
49
+ branch="brain-app", base="main",
50
+ )
51
+ # -> {"sha": "abc1234", "committed": True, "branch": "brain-app"}
52
+ ```
53
+
54
+ Auth: embed a token in `remote_url` for HTTPS (`https://oauth2:<token>@host/repo.git`), or pass
55
+ `key_path=` (+ optional `known_hosts=`) for an SSH remote. Serialize concurrent writers on one shared
56
+ clone with `with repo.lock(): ...`.
57
+
58
+ ## API
59
+ `GitRepo(path, remote_url, branch, key_path, known_hosts, author_name, author_email, timeout)` with:
60
+ `ensure_clone()` · `clone_or_refresh()` · `pull_rebase()` · `prepare_branch(branch, base)` ·
61
+ `write_files(files)` · `remove_files(paths)` · `move(src, dest)` · `commit(paths, message, *, stage=True)` ·
62
+ `push(branch)` · `commit_files(...)` (high-level) · `status()` · `lock()`. Working-tree inspection:
63
+ `diff(paths=None, *, staged=None)` · `changed_files(paths=None) -> list[FileChange]`. Failures raise
64
+ `GitError` / `Conflict` (both subclass `GitToolsError`).
65
+
66
+ `changed_files` parses `git status --porcelain` into `FileChange(path, status, staged)` records — for a
67
+ consumer that works on an *already-checked-out* repo (status/diff/move, no push), e.g. ecosystem_status'
68
+ `wip-ia-code/` flow. (Distinct from `status()`, which probes clone liveness / HEAD.)
69
+
70
+ ```python
71
+ for c in repo.changed_files(["wip-ia-code/"]): # status --porcelain, scoped
72
+ print(c.path, c.status, "staged" if c.staged else "unstaged")
73
+ print(repo.diff(["wip-ia-code/"])) # combined worktree + cached diff
74
+ repo.move("wip-ia-code/a.md", "wip-ia-code/plans/a.md") # git mv
75
+ ```
76
+
77
+ Delete a subtree symmetrically with the write path — `git rm` stages the removal, so commit with
78
+ `stage=False` (re-adding a now-absent path would fail):
79
+
80
+ ```python
81
+ repo.pull_rebase()
82
+ repo.remove_files(["myapp"]) # git rm -r -- myapp
83
+ res = repo.commit(["myapp"], "drop myapp", stage=False)
84
+ if res["committed"]:
85
+ repo.push()
86
+ ```
@@ -0,0 +1,6 @@
1
+ git_tools/__init__.py,sha256=zuh7PjWp5xsIeoQmeu2FhDNDaxxHE_WY390HCyf-koQ,523
2
+ git_tools/core.py,sha256=CBDPr_bMelqRYU-MeFLjUV9aFKUFhABZo9MMpzNm5Rg,13460
3
+ fastpluggy_git_tools-0.2.1.dist-info/METADATA,sha256=DN0557POaf0UQlPhXGzbB2ElsqqHwgDkcbiKFoQQKeo,4236
4
+ fastpluggy_git_tools-0.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ fastpluggy_git_tools-0.2.1.dist-info/top_level.txt,sha256=WHcKtoU3tlieH2F6-DAU6gAruDSGuNN7upNv7cxOHnE,10
6
+ fastpluggy_git_tools-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ git_tools
git_tools/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """git_tools — a small, stdlib-only managed-clone GitOps client.
2
+
3
+ from git_tools import GitRepo
4
+ repo = GitRepo(path="/data/kb/fp-tmp-personal",
5
+ remote_url="https://oauth2:<token>@gitlab.example/grp/repo.git")
6
+ repo.commit_files({"_raw/brain/x/content.md": "..."},
7
+ "brain capture", branch="brain-app", base="main")
8
+ """
9
+ from .core import Conflict, FileChange, GitError, GitRepo, GitToolsError
10
+
11
+ __all__ = ["GitRepo", "FileChange", "GitToolsError", "GitError", "Conflict"]
git_tools/core.py ADDED
@@ -0,0 +1,310 @@
1
+ """A small, stdlib-only GitOps client for a managed working clone.
2
+
3
+ Extracted and generalized from ``fp-deployer/src/apps_repo.py`` (the proven
4
+ pattern: ``pull --rebase --autostash`` → no-change short-circuit → conflict-
5
+ marker refusal → identity-via-``-c`` commit → push, **never force-push**),
6
+ adding the bits a content-repo caller (e.g. the brain app committing KB
7
+ captures) needs that the deployer didn't: **clone-if-missing** and
8
+ **feature-branch** support.
9
+
10
+ Design: subprocess ``git`` only (no GitPython — keeps callers dependency-light;
11
+ ``ecosystem_status`` deliberately avoids the optional dep). Auth is carried by
12
+ ``remote_url`` — embed a token for HTTPS (``https://oauth2:<token>@host/repo.git``)
13
+ or pass ``key_path`` for an SSH remote. One clone lives on disk (``/data/...``);
14
+ serialize concurrent writers with ``lock()``.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ import shlex
21
+ import subprocess
22
+ from contextlib import contextmanager
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _BOT_NAME = "git-tools"
29
+ _BOT_EMAIL = "git-tools@homelab"
30
+
31
+
32
+ class GitToolsError(Exception):
33
+ """Base error (always surfaced to the caller)."""
34
+
35
+
36
+ class GitError(GitToolsError):
37
+ """A git subprocess exited non-zero. Carries argv + stdout/stderr."""
38
+
39
+ def __init__(self, argv: list[str], returncode: int, stdout: str, stderr: str):
40
+ self.argv, self.returncode, self.stdout, self.stderr = argv, returncode, stdout, stderr
41
+ msg = stderr.strip() or stdout.strip() or f"git {shlex.join(argv)} exit {returncode}"
42
+ super().__init__(msg)
43
+
44
+
45
+ class Conflict(GitError):
46
+ """A rebase/merge needs manual resolution (rebase aborted; never forced)."""
47
+
48
+
49
+ # Porcelain status code -> human label (``?`` and ``C`` map to "new").
50
+ _STATUS_LABELS = {"M": "modified", "A": "new", "D": "deleted", "R": "renamed",
51
+ "?": "new", "C": "new"}
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class FileChange:
56
+ """One changed path from :meth:`GitRepo.changed_files`. A path changed in
57
+ both the index and the worktree yields two records (staged + unstaged)."""
58
+
59
+ path: str
60
+ status: str # "modified" | "new" | "deleted" | "renamed"
61
+ staged: bool
62
+
63
+
64
+ @dataclass
65
+ class GitRepo:
66
+ """A long-lived working clone on disk (typically under ``/data``).
67
+
68
+ ``remote_url`` carries auth (HTTPS token or SSH); ``key_path`` is an optional
69
+ SSH key. ``branch`` is the default branch. Methods are subprocess-only and
70
+ raise :class:`GitError` (or :class:`Conflict`) on failure.
71
+ """
72
+
73
+ path: Path
74
+ remote_url: str = ""
75
+ branch: str = "main"
76
+ key_path: str = ""
77
+ known_hosts: str = ""
78
+ author_name: str = _BOT_NAME
79
+ author_email: str = _BOT_EMAIL
80
+ timeout: int = 120
81
+
82
+ def __post_init__(self) -> None:
83
+ self.path = Path(self.path)
84
+
85
+ # ---------- low-level ---------------------------------------------------
86
+ def _env(self) -> dict[str, str]:
87
+ env = os.environ.copy()
88
+ if self.key_path: # SSH remotes: pin the key, no agent/interaction
89
+ opts = [f"-i {self.key_path}", "-o IdentitiesOnly=yes", "-o BatchMode=yes",
90
+ "-o StrictHostKeyChecking=yes"]
91
+ if self.known_hosts:
92
+ opts.append(f"-o UserKnownHostsFile={self.known_hosts}")
93
+ env["GIT_SSH_COMMAND"] = "ssh " + " ".join(opts)
94
+ env.setdefault("GIT_TERMINAL_PROMPT", "0") # never block on credential prompts
95
+ return env
96
+
97
+ def _run(self, argv: list[str], cwd: Path) -> str:
98
+ res = subprocess.run( # noqa: S603 — controlled argv, never shell=True
99
+ argv, cwd=str(cwd), env=self._env(),
100
+ capture_output=True, text=True, timeout=self.timeout,
101
+ )
102
+ if res.returncode != 0:
103
+ raise GitError(argv, res.returncode, res.stdout, res.stderr)
104
+ return res.stdout
105
+
106
+ def _git(self, *args: str) -> str:
107
+ return self._run(["git", *args], self.path)
108
+
109
+ # ---------- clone / sync ------------------------------------------------
110
+ @property
111
+ def cloned(self) -> bool:
112
+ return (self.path / ".git").is_dir()
113
+
114
+ def ensure_clone(self) -> None:
115
+ """Clone ``remote_url`` into ``path`` if not already a working clone."""
116
+ if self.cloned:
117
+ return
118
+ if not self.remote_url:
119
+ raise GitToolsError("ensure_clone: no remote_url configured")
120
+ self.path.parent.mkdir(parents=True, exist_ok=True)
121
+ self._run(["git", "clone", self.remote_url, str(self.path)], self.path.parent)
122
+
123
+ def clone_or_refresh(self) -> None:
124
+ """Clone if missing, else ``pull --rebase --autostash`` the branch."""
125
+ if not self.cloned:
126
+ self.ensure_clone()
127
+ return
128
+ self.pull_rebase()
129
+
130
+ def pull_rebase(self, branch: str | None = None) -> None:
131
+ """``git pull --rebase --autostash origin <branch>``; never force.
132
+
133
+ On rebase failure, abort + raise :class:`Conflict`. A residual stash
134
+ after a *successful* pull means the autostash pop conflicted (markers
135
+ now in the worktree) — also raised as a Conflict (the apps_repo caveat).
136
+ """
137
+ br = branch or self.branch
138
+ try:
139
+ self._git("pull", "--rebase", "--autostash", "origin", br)
140
+ except GitError as exc:
141
+ try:
142
+ self._git("rebase", "--abort")
143
+ except GitError:
144
+ pass
145
+ raise Conflict(exc.argv, exc.returncode, exc.stdout,
146
+ f"pull --rebase failed; manual resolution required.\n{exc.stderr}") from exc
147
+ if self._git("stash", "list").strip():
148
+ raise Conflict(("pull", "--rebase", "--autostash"), 0, "",
149
+ "autostash pop conflicted — unresolved markers in the worktree.")
150
+
151
+ # ---------- branch / write / commit ------------------------------------
152
+ def prepare_branch(self, branch: str, base: str = "main") -> None:
153
+ """Check out ``branch`` for writing: track the remote branch if it
154
+ exists, else create it fresh off ``origin/<base>``."""
155
+ self._git("fetch", "origin")
156
+ remote_has = False
157
+ try:
158
+ self._git("rev-parse", "--verify", f"origin/{branch}")
159
+ remote_has = True
160
+ except GitError:
161
+ remote_has = False
162
+ if remote_has:
163
+ self._git("checkout", "-B", branch, f"origin/{branch}")
164
+ else:
165
+ self._git("checkout", "-B", branch, f"origin/{base}")
166
+
167
+ def write_files(self, files: dict[str, str]) -> None:
168
+ """Write ``{relpath: content}`` into the worktree (creates parent dirs)."""
169
+ for rel, content in files.items():
170
+ dest = self.path / rel
171
+ dest.parent.mkdir(parents=True, exist_ok=True)
172
+ dest.write_text(content)
173
+
174
+ def remove_files(self, paths: list[str]) -> None:
175
+ """``git rm -r`` the given paths (stages the deletion). Symmetric with
176
+ :meth:`write_files`; pair with ``commit(paths, msg, stage=False)`` so the
177
+ already-staged removal isn't re-added — ``git add`` on a now-absent path
178
+ would fail. The caller guards existence: ``git rm`` raises
179
+ :class:`GitError` on an untracked/unknown path."""
180
+ self._git("rm", "-r", "--", *paths)
181
+
182
+ def move(self, src: str, dest: str) -> None:
183
+ """``git mv src dest`` — creating the destination's parent dir first."""
184
+ (self.path / dest).parent.mkdir(parents=True, exist_ok=True)
185
+ self._git("mv", src, dest)
186
+
187
+ # ---------- working-tree inspection ------------------------------------
188
+ def diff(self, paths: list[str] | None = None, *, staged: bool | None = None) -> str:
189
+ """Unified diff of the working tree.
190
+
191
+ ``staged``: ``None`` → combined worktree + cached, ``True`` → ``--cached``
192
+ only, ``False`` → worktree only. ``paths`` restricts to those pathspecs.
193
+ """
194
+ targets = ["--", *paths] if paths else []
195
+ if staged is True:
196
+ return self._git("diff", "--cached", *targets)
197
+ if staged is False:
198
+ return self._git("diff", *targets)
199
+ parts = []
200
+ for extra in ([], ["--cached"]):
201
+ out = self._git("diff", *extra, *targets)
202
+ if out.strip():
203
+ parts.append(out)
204
+ return "\n".join(parts)
205
+
206
+ def changed_files(self, paths: list[str] | None = None) -> list[FileChange]:
207
+ """Working-tree changes via ``git status --porcelain -uall``, parsed into
208
+ :class:`FileChange` records. A path changed in both the index and the
209
+ worktree yields two records (staged + unstaged); renames report the new
210
+ path. ``paths`` restricts the scan.
211
+
212
+ NB: distinct from :meth:`status` — that probes *clone liveness* (HEAD /
213
+ clone_present); this reports *uncommitted changes*."""
214
+ targets = ["--", *paths] if paths else []
215
+ out = self._git("status", "--porcelain", "-uall", *targets)
216
+ changes: list[FileChange] = []
217
+ for line in out.splitlines():
218
+ if len(line) < 4:
219
+ continue
220
+ index_code, worktree_code, rawpath = line[0], line[1], line[3:]
221
+ if " -> " in rawpath: # rename/copy: take the new path
222
+ rawpath = rawpath.split(" -> ", 1)[1]
223
+ if index_code not in (" ", "?"):
224
+ changes.append(FileChange(rawpath, _STATUS_LABELS.get(index_code, "modified"), staged=True))
225
+ if worktree_code != " ":
226
+ changes.append(FileChange(rawpath, _STATUS_LABELS.get(worktree_code, "modified"), staged=False))
227
+ return changes
228
+
229
+ def commit(self, paths: list[str], message: str, *, stage: bool = True) -> dict[str, object]:
230
+ """Stage ``paths`` and commit if anything changed. Returns
231
+ ``{sha, committed}``. Pass ``stage=False`` when the change is already
232
+ staged (e.g. a removal via :meth:`remove_files`), so it isn't re-added.
233
+ Refuses to commit conflict markers; identity is set via ``-c`` (never
234
+ mutates global git config)."""
235
+ if stage:
236
+ self._git("add", "--", *paths)
237
+ if not self._git("status", "--porcelain", "--", *paths).strip():
238
+ return {"sha": self._git("rev-parse", "--short", "HEAD").strip(), "committed": False}
239
+ try:
240
+ self._git("diff", "--check", "--cached")
241
+ except GitError as exc:
242
+ raise Conflict(exc.argv, exc.returncode, exc.stdout,
243
+ "refusing to commit: staged content contains conflict markers.") from exc
244
+ self._git("-c", f"user.name={self.author_name}", "-c", f"user.email={self.author_email}",
245
+ "commit", "-m", message)
246
+ return {"sha": self._git("rev-parse", "--short", "HEAD").strip(), "committed": True}
247
+
248
+ def push(self, branch: str | None = None) -> None:
249
+ br = branch or self.branch
250
+ self._git("push", "-u", "origin", br)
251
+
252
+ # ---------- high-level convenience -------------------------------------
253
+ def commit_files(self, files: dict[str, str], message: str, *,
254
+ branch: str | None = None, base: str = "main", push: bool = True) -> dict[str, object]:
255
+ """Clone-if-needed → (optional feature branch) → write → commit → push.
256
+
257
+ The one-call path for a caller like the brain app: drop a set of files
258
+ on a (rolling) branch and open them for triage. Idempotent — identical
259
+ content returns ``committed: False`` without an empty commit.
260
+ """
261
+ self.ensure_clone()
262
+ target = branch or self.branch
263
+ if branch:
264
+ self.prepare_branch(branch, base=base)
265
+ else:
266
+ self.pull_rebase(target)
267
+ self.write_files(files)
268
+ result = self.commit(list(files), message)
269
+ if push and result["committed"]:
270
+ self.push(target)
271
+ result["branch"] = target
272
+ return result
273
+
274
+ # ---------- introspection ----------------------------------------------
275
+ def status(self) -> dict[str, object]:
276
+ """Best-effort liveness probe (never raises)."""
277
+ out: dict[str, object] = {
278
+ "path": str(self.path), "branch": self.branch,
279
+ "clone_present": self.cloned, "head": None, "error": None,
280
+ }
281
+ if self.cloned:
282
+ try:
283
+ out["head"] = self._git("rev-parse", "--short", "HEAD").strip()
284
+ except GitError as exc:
285
+ out["error"] = str(exc)
286
+ else:
287
+ out["error"] = f"clone missing (no .git in {self.path})"
288
+ return out
289
+
290
+ @contextmanager
291
+ def lock(self):
292
+ """Serialize writers on this clone via an O_EXCL lockfile.
293
+
294
+ Use around a clone→commit→push cycle when several callers share one
295
+ on-disk clone — including the *first* call, when ``path`` is still
296
+ empty. The lockfile lives in the **parent** dir (a sibling of ``path``),
297
+ never inside it, so it can't make ``git clone`` refuse a non-empty
298
+ destination. Best-effort cleanup on exit.
299
+ """
300
+ self.path.parent.mkdir(parents=True, exist_ok=True)
301
+ lockfile = self.path.parent / f".{self.path.name}.git-tools.lock"
302
+ fd = os.open(str(lockfile), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
303
+ try:
304
+ yield self
305
+ finally:
306
+ os.close(fd)
307
+ try:
308
+ os.unlink(str(lockfile))
309
+ except OSError:
310
+ pass