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
|
+
[](https://gitlab.ggcorp.fr/open/fastpluggy/private_plugins/git_tools/-/pipelines)
|
|
16
|
+
[](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 @@
|
|
|
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
|