fastpluggy-git-tools 0.2.1__tar.gz

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,74 @@
1
+ # git_tools
2
+
3
+ [![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)
4
+ [![version](https://img.shields.io/badge/version-0.2.1-blue)](https://gitlab.ggcorp.fr/open/fastpluggy/private_plugins/git_tools/-/releases)
5
+
6
+ A small, **stdlib-only** managed-clone GitOps client — clone / branch / commit / push for a long-lived
7
+ working clone (typically on `/data`). **Not a FastPluggy plugin**, just a library you `pip install` and
8
+ import.
9
+
10
+ Extracted and generalized from the proven git logic in `fp-deployer/apps_repo.py` (the careful bits:
11
+ `pull --rebase --autostash`, no-change short-circuit, **conflict-marker refusal**, identity via `-c`,
12
+ **never force-push**), adding **clone-if-missing** and **feature-branch** support for content-repo callers
13
+ like the brain app committing KB captures.
14
+
15
+ ## Why not GitPython / the GitLab API
16
+ - **Subprocess git, no GitPython** → consumers stay dependency-light (and it matches `fp-deployer` /
17
+ `ecosystem_status`, which deliberately avoid the optional dep). It also lets you run the repo's *own*
18
+ scripts against the clone (e.g. the KB's `status.py` / `kb_checks.py`).
19
+ - The GitLab Commits API is the clone-free alternative (what `brain/kb_capture.py` uses today); it's fine
20
+ for simple text commits but blind to repo state. `git_tools` is the clone-based path.
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from git_tools import GitRepo
26
+
27
+ repo = GitRepo(
28
+ path="/data/kb/fp-tmp-personal",
29
+ remote_url="https://oauth2:<token>@gitlab.ggcorp.fr/JeJe/fp-tmp-personal.git", # or git@… + key_path
30
+ branch="main",
31
+ )
32
+
33
+ # One call: clone-if-needed → feature branch off main → write → commit → push (idempotent).
34
+ res = repo.commit_files(
35
+ {"_raw/brain/2026-06-12_x_42/content.md": "...", "_raw/brain/2026-06-12_x_42/meta.yml": "source: brain\n"},
36
+ "brain capture: x (share 42)",
37
+ branch="brain-app", base="main",
38
+ )
39
+ # -> {"sha": "abc1234", "committed": True, "branch": "brain-app"}
40
+ ```
41
+
42
+ Auth: embed a token in `remote_url` for HTTPS (`https://oauth2:<token>@host/repo.git`), or pass
43
+ `key_path=` (+ optional `known_hosts=`) for an SSH remote. Serialize concurrent writers on one shared
44
+ clone with `with repo.lock(): ...`.
45
+
46
+ ## API
47
+ `GitRepo(path, remote_url, branch, key_path, known_hosts, author_name, author_email, timeout)` with:
48
+ `ensure_clone()` · `clone_or_refresh()` · `pull_rebase()` · `prepare_branch(branch, base)` ·
49
+ `write_files(files)` · `remove_files(paths)` · `move(src, dest)` · `commit(paths, message, *, stage=True)` ·
50
+ `push(branch)` · `commit_files(...)` (high-level) · `status()` · `lock()`. Working-tree inspection:
51
+ `diff(paths=None, *, staged=None)` · `changed_files(paths=None) -> list[FileChange]`. Failures raise
52
+ `GitError` / `Conflict` (both subclass `GitToolsError`).
53
+
54
+ `changed_files` parses `git status --porcelain` into `FileChange(path, status, staged)` records — for a
55
+ consumer that works on an *already-checked-out* repo (status/diff/move, no push), e.g. ecosystem_status'
56
+ `wip-ia-code/` flow. (Distinct from `status()`, which probes clone liveness / HEAD.)
57
+
58
+ ```python
59
+ for c in repo.changed_files(["wip-ia-code/"]): # status --porcelain, scoped
60
+ print(c.path, c.status, "staged" if c.staged else "unstaged")
61
+ print(repo.diff(["wip-ia-code/"])) # combined worktree + cached diff
62
+ repo.move("wip-ia-code/a.md", "wip-ia-code/plans/a.md") # git mv
63
+ ```
64
+
65
+ Delete a subtree symmetrically with the write path — `git rm` stages the removal, so commit with
66
+ `stage=False` (re-adding a now-absent path would fail):
67
+
68
+ ```python
69
+ repo.pull_rebase()
70
+ repo.remove_files(["myapp"]) # git rm -r -- myapp
71
+ res = repo.commit(["myapp"], "drop myapp", stage=False)
72
+ if res["committed"]:
73
+ repo.push()
74
+ ```
@@ -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,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ fastpluggy_git_tools.egg-info/PKG-INFO
4
+ fastpluggy_git_tools.egg-info/SOURCES.txt
5
+ fastpluggy_git_tools.egg-info/dependency_links.txt
6
+ fastpluggy_git_tools.egg-info/requires.txt
7
+ fastpluggy_git_tools.egg-info/top_level.txt
8
+ src/git_tools/__init__.py
9
+ src/git_tools/core.py
10
+ tests/test_git_repo.py
@@ -0,0 +1,4 @@
1
+
2
+ [tests]
3
+ pytest>=7.0
4
+ pytest-cov>=4.0
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fastpluggy-git-tools"
7
+ version = "0.2.1"
8
+ description = "Small stdlib-only managed-clone GitOps client (clone/branch/commit/push) — not a FastPluggy plugin, just a library."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "FastPluggy Team" }]
13
+ dependencies = [] # stdlib only (subprocess git) — keep consumers dependency-light
14
+
15
+ [project.optional-dependencies]
16
+ tests = ["pytest>=7.0", "pytest-cov>=4.0"] # CI template runs pytest --cov
17
+
18
+ [tool.setuptools]
19
+ packages = ["git_tools"]
20
+ package-dir = { "git_tools" = "src/git_tools" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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
@@ -0,0 +1,184 @@
1
+ """Real-git tests against a local bare "remote" (offline, no network)."""
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from git_tools import FileChange, GitRepo
8
+
9
+
10
+ def _git(cwd: Path, *args: str) -> str:
11
+ return subprocess.run(
12
+ ["git", "-c", "user.name=t", "-c", "user.email=t@t", *args],
13
+ cwd=str(cwd), capture_output=True, text=True, check=True,
14
+ ).stdout
15
+
16
+
17
+ @pytest.fixture
18
+ def remote(tmp_path: Path) -> Path:
19
+ """A bare repo seeded with one commit on `main` — stands in for origin."""
20
+ bare = tmp_path / "remote.git"
21
+ bare.mkdir()
22
+ _git(bare, "init", "--bare", "-b", "main")
23
+ seed = tmp_path / "seed"
24
+ _git(tmp_path, "clone", str(bare), str(seed))
25
+ (seed / "README.md").write_text("seed\n")
26
+ _git(seed, "checkout", "-B", "main")
27
+ _git(seed, "add", "README.md")
28
+ _git(seed, "commit", "-m", "seed")
29
+ _git(seed, "push", "-u", "origin", "main")
30
+ return bare
31
+
32
+
33
+ def _repo(tmp_path: Path, remote: Path, **kw) -> GitRepo:
34
+ return GitRepo(path=tmp_path / "clone", remote_url=str(remote), branch="main", **kw)
35
+
36
+
37
+ def test_commit_files_on_feature_branch(tmp_path, remote):
38
+ repo = _repo(tmp_path, remote)
39
+ res = repo.commit_files(
40
+ {"_raw/brain/x/content.md": "hello", "_raw/brain/x/meta.yml": "source: brain\n"},
41
+ "brain capture", branch="inbox", base="main",
42
+ )
43
+ assert res["committed"] is True and res["branch"] == "inbox"
44
+
45
+ # The bare remote now has the files on the `inbox` branch.
46
+ verify = tmp_path / "verify"
47
+ _git(tmp_path, "clone", "-b", "inbox", str(remote), str(verify))
48
+ assert (verify / "_raw/brain/x/content.md").read_text() == "hello"
49
+
50
+
51
+ def test_no_change_short_circuit(tmp_path, remote):
52
+ repo = _repo(tmp_path, remote)
53
+ files = {"a.md": "same"}
54
+ first = repo.commit_files(files, "first", branch="inbox", base="main")
55
+ assert first["committed"] is True
56
+ again = repo.commit_files(files, "again", branch="inbox", base="main")
57
+ assert again["committed"] is False # identical content → no empty commit
58
+
59
+
60
+ def test_remove_files_deletes_subtree(tmp_path, remote):
61
+ repo = _repo(tmp_path, remote)
62
+ # Seed a subtree on `inbox`, then delete it via remove_files + commit(stage=False).
63
+ repo.commit_files({"myapp/a.txt": "x", "myapp/b.txt": "y"}, "add myapp",
64
+ branch="inbox", base="main")
65
+ repo.remove_files(["myapp"])
66
+ res = repo.commit(["myapp"], "drop myapp", stage=False)
67
+ assert res["committed"] is True
68
+ assert not (repo.path / "myapp").exists()
69
+ repo.push("inbox")
70
+
71
+ # The deletion is on the remote `inbox` branch.
72
+ verify = tmp_path / "verify"
73
+ _git(tmp_path, "clone", "-b", "inbox", str(remote), str(verify))
74
+ assert not (verify / "myapp").exists()
75
+
76
+
77
+ def test_remove_files_unknown_path_raises(tmp_path, remote):
78
+ from git_tools import GitError
79
+ repo = _repo(tmp_path, remote)
80
+ repo.clone_or_refresh()
81
+ with pytest.raises(GitError):
82
+ repo.remove_files(["does-not-exist"]) # git rm refuses an untracked path
83
+
84
+
85
+ def test_clone_or_refresh_is_idempotent(tmp_path, remote):
86
+ repo = _repo(tmp_path, remote)
87
+ repo.clone_or_refresh()
88
+ assert repo.cloned and repo.status()["head"]
89
+ repo.clone_or_refresh() # second call: pull-rebase, no error
90
+ assert repo.cloned
91
+
92
+
93
+ def test_status_before_clone(tmp_path, remote):
94
+ repo = _repo(tmp_path, remote)
95
+ st = repo.status()
96
+ assert st["clone_present"] is False and st["error"]
97
+
98
+
99
+ def test_lock_around_first_clone(tmp_path, remote):
100
+ """lock() must not pollute an as-yet-empty clone target: the lockfile is a
101
+ sibling, so clone-under-lock (the documented first-call path) works."""
102
+ repo = _repo(tmp_path, remote)
103
+ with repo.lock():
104
+ res = repo.commit_files({"a.md": "x"}, "first", branch="inbox", base="main")
105
+ assert res["committed"] is True and repo.cloned
106
+ # lockfile is released and lived outside the clone dir
107
+ assert not (repo.path / ".git-tools.lock").exists()
108
+ assert not (tmp_path / f".{repo.path.name}.git-tools.lock").exists()
109
+
110
+
111
+ def test_lock_is_exclusive(tmp_path, remote):
112
+ repo = _repo(tmp_path, remote)
113
+ with repo.lock():
114
+ with pytest.raises(FileExistsError):
115
+ with repo.lock():
116
+ pass
117
+
118
+
119
+ # ---------- working-tree primitives (0.2.0) --------------------------------
120
+
121
+ @pytest.fixture
122
+ def cloned(tmp_path, remote):
123
+ """A fresh clone of the seeded remote, ready for working-tree edits."""
124
+ repo = _repo(tmp_path, remote)
125
+ repo.clone_or_refresh()
126
+ return repo
127
+
128
+
129
+ def test_move_git_mvs_and_creates_parent(cloned):
130
+ cloned.move("README.md", "docs/README.md")
131
+ assert not (cloned.path / "README.md").exists()
132
+ assert (cloned.path / "docs/README.md").read_text() == "seed\n"
133
+ # git mv stages the rename.
134
+ assert any(c.status == "renamed" and c.staged for c in cloned.changed_files())
135
+
136
+
137
+ def test_changed_files_modified_unstaged(cloned):
138
+ (cloned.path / "README.md").write_text("changed\n")
139
+ changes = cloned.changed_files()
140
+ assert changes == [FileChange("README.md", "modified", staged=False)]
141
+
142
+
143
+ def test_changed_files_new_and_staged_and_deleted(cloned):
144
+ # First land a tracked file so we can delete it later (commit it alone).
145
+ (cloned.path / "extra.txt").write_text("y")
146
+ _git(cloned.path, "add", "extra.txt")
147
+ _git(cloned.path, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "add extra")
148
+
149
+ (cloned.path / "new.txt").write_text("x") # untracked new file
150
+ (cloned.path / "README.md").write_text("v2\n") # staged modification
151
+ _git(cloned.path, "add", "README.md")
152
+ (cloned.path / "extra.txt").unlink() # worktree deletion, unstaged
153
+
154
+ by_path = {(c.path, c.staged): c.status for c in cloned.changed_files()}
155
+ assert by_path[("new.txt", False)] == "new" # untracked -> new, unstaged
156
+ assert by_path[("README.md", True)] == "modified" # staged modification
157
+ assert by_path[("extra.txt", False)] == "deleted" # worktree deletion, unstaged
158
+
159
+
160
+ def test_changed_files_scoped_to_paths(cloned):
161
+ (cloned.path / "in_scope.txt").write_text("a")
162
+ (cloned.path / "out_of_scope.txt").write_text("b")
163
+ paths = [c.path for c in cloned.changed_files(["in_scope.txt"])]
164
+ assert paths == ["in_scope.txt"]
165
+
166
+
167
+ def test_changed_files_clean_tree_is_empty(cloned):
168
+ assert cloned.changed_files() == []
169
+
170
+
171
+ def test_diff_modes(cloned):
172
+ (cloned.path / "README.md").write_text("worktree change\n") # unstaged
173
+ (cloned.path / "staged.txt").write_text("staged content\n")
174
+ _git(cloned.path, "add", "staged.txt") # staged
175
+
176
+ worktree = cloned.diff(staged=False)
177
+ cached = cloned.diff(staged=True)
178
+ combined = cloned.diff()
179
+
180
+ assert "worktree change" in worktree and "staged content" not in worktree
181
+ assert "staged content" in cached and "worktree change" not in cached
182
+ assert "worktree change" in combined and "staged content" in combined
183
+ # path scoping restricts the diff
184
+ assert "worktree change" not in cloned.diff(["staged.txt"], staged=True)