chp-adapter-git 0.8.0__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,684 @@
|
|
|
1
|
+
"""GitAdapter — local Git repository inspection and operations as CHP capabilities.
|
|
2
|
+
|
|
3
|
+
Evidence hygiene (MUST PRESERVE):
|
|
4
|
+
* Diff content (patch text) — NEVER in evidence; only ``files_changed``,
|
|
5
|
+
``insertions``, ``deletions`` counts.
|
|
6
|
+
* File content — NEVER in evidence.
|
|
7
|
+
* Full commit message bodies beyond the subject line — NOT in evidence; only
|
|
8
|
+
first 80 chars of each commit subject.
|
|
9
|
+
* Unstaged/staged file content — NEVER in evidence; only file path lists.
|
|
10
|
+
|
|
11
|
+
Seven capabilities:
|
|
12
|
+
|
|
13
|
+
* ``status`` — working tree status: branch, staged/unstaged/untracked counts
|
|
14
|
+
* ``inspect_repo`` — branch, HEAD SHA, remotes, contributor count
|
|
15
|
+
* ``log`` — recent commits list (sha7, author, subject, date)
|
|
16
|
+
* ``diff_summary`` — files changed, insertions, deletions (NO patch text)
|
|
17
|
+
* ``precommit_check`` — staged file list + unstaged changes count
|
|
18
|
+
* ``checkout_branch`` — create or switch to a branch
|
|
19
|
+
* ``commit`` — stage specified files and commit with a message
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import subprocess
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Protocol, runtime_checkable
|
|
28
|
+
|
|
29
|
+
from chp_core import BaseAdapter, capability
|
|
30
|
+
|
|
31
|
+
_EMITS = ["git_request", "git_response", "git_error"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Injectable backend
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class GitBackend(Protocol):
|
|
40
|
+
"""Minimal interface for running git sub-commands."""
|
|
41
|
+
|
|
42
|
+
def run(self, *args: str, cwd: str | None = None) -> str:
|
|
43
|
+
"""Run ``git <args>`` and return stdout. Raises RuntimeError on non-zero exit."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SubprocessGitBackend:
|
|
48
|
+
"""Production backend: delegates to the ``git`` binary via subprocess."""
|
|
49
|
+
|
|
50
|
+
def run(self, *args: str, cwd: str | None = None) -> str:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["git", *args],
|
|
53
|
+
cwd=cwd,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
)
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
raise RuntimeError(result.stderr.strip() or f"git {args[0]} failed")
|
|
59
|
+
return result.stdout.strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Config
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class GitConfig:
|
|
68
|
+
"""Config for GitAdapter.
|
|
69
|
+
|
|
70
|
+
``default_repo_path`` can be overridden per-call via the ``repo_path``
|
|
71
|
+
payload field. ``backend`` accepts a test double for unit isolation.
|
|
72
|
+
``max_log_entries`` caps how many commits ``log`` returns.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
default_repo_path: str | None = None
|
|
76
|
+
max_log_entries: int = 50
|
|
77
|
+
backend: Any = None # GitBackend implementation
|
|
78
|
+
|
|
79
|
+
def _effective_repo_path(self) -> str:
|
|
80
|
+
return self.default_repo_path or os.getcwd()
|
|
81
|
+
|
|
82
|
+
def _effective_backend(self) -> GitBackend:
|
|
83
|
+
return self.backend if self.backend is not None else SubprocessGitBackend()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Adapter
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
class GitAdapter(BaseAdapter):
|
|
91
|
+
"""Git repository inspection and lightweight write operations."""
|
|
92
|
+
|
|
93
|
+
adapter_id = "chp.adapters.git"
|
|
94
|
+
adapter_name = "Git"
|
|
95
|
+
adapter_description = "Local Git repository inspection and operations"
|
|
96
|
+
adapter_category = "developer_tooling"
|
|
97
|
+
adapter_tags = ["git", "vcs", "repository", "diff", "commit"]
|
|
98
|
+
|
|
99
|
+
def __init__(self, config: GitConfig | None = None) -> None:
|
|
100
|
+
self._config = config or GitConfig()
|
|
101
|
+
|
|
102
|
+
def _backend(self) -> GitBackend:
|
|
103
|
+
return self._config._effective_backend()
|
|
104
|
+
|
|
105
|
+
def _repo(self, payload: dict) -> str:
|
|
106
|
+
return payload.get("repo_path") or self._config._effective_repo_path()
|
|
107
|
+
|
|
108
|
+
def _git(self, *args: str, repo: str) -> str:
|
|
109
|
+
return self._backend().run(*args, cwd=repo)
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# status
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
@capability(
|
|
116
|
+
id="chp.adapters.git.status",
|
|
117
|
+
version="0.1.0",
|
|
118
|
+
description="Working tree status: branch name, staged/unstaged/untracked file counts.",
|
|
119
|
+
category="developer_tooling",
|
|
120
|
+
risk="low",
|
|
121
|
+
input_schema={
|
|
122
|
+
"type": "object",
|
|
123
|
+
"properties": {
|
|
124
|
+
"repo_path": {"type": "string", "description": "Absolute path to the git repo (defaults to config)"},
|
|
125
|
+
},
|
|
126
|
+
"additionalProperties": False,
|
|
127
|
+
},
|
|
128
|
+
emits=_EMITS,
|
|
129
|
+
tags=["git", "status"],
|
|
130
|
+
)
|
|
131
|
+
async def status(self, ctx: Any, payload: dict) -> dict:
|
|
132
|
+
repo = self._repo(payload)
|
|
133
|
+
ctx.emit("git_request", {"operation": "status", "repo_path": repo})
|
|
134
|
+
try:
|
|
135
|
+
branch = self._git("rev-parse", "--abbrev-ref", "HEAD", repo=repo)
|
|
136
|
+
porcelain = self._git("status", "--porcelain", repo=repo)
|
|
137
|
+
except RuntimeError as exc:
|
|
138
|
+
ctx.emit("git_error", {"operation": "status", "error": str(exc)})
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
staged = unstaged = untracked = 0
|
|
142
|
+
for line in porcelain.splitlines():
|
|
143
|
+
if len(line) < 2:
|
|
144
|
+
continue
|
|
145
|
+
x, y = line[0], line[1]
|
|
146
|
+
if x == "?" and y == "?":
|
|
147
|
+
untracked += 1
|
|
148
|
+
elif x != " " and x != "?":
|
|
149
|
+
staged += 1
|
|
150
|
+
if y not in (" ", "?"):
|
|
151
|
+
unstaged += 1
|
|
152
|
+
|
|
153
|
+
result = {
|
|
154
|
+
"branch": branch,
|
|
155
|
+
"staged": staged,
|
|
156
|
+
"unstaged": unstaged,
|
|
157
|
+
"untracked": untracked,
|
|
158
|
+
"clean": staged == 0 and unstaged == 0 and untracked == 0,
|
|
159
|
+
}
|
|
160
|
+
ctx.emit("git_response", {"operation": "status", **result})
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
# inspect_repo
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
@capability(
|
|
168
|
+
id="chp.adapters.git.inspect_repo",
|
|
169
|
+
version="0.1.0",
|
|
170
|
+
description="Repository overview: branch, HEAD SHA, remotes, commit count.",
|
|
171
|
+
category="developer_tooling",
|
|
172
|
+
risk="low",
|
|
173
|
+
input_schema={
|
|
174
|
+
"type": "object",
|
|
175
|
+
"properties": {
|
|
176
|
+
"repo_path": {"type": "string"},
|
|
177
|
+
},
|
|
178
|
+
"additionalProperties": False,
|
|
179
|
+
},
|
|
180
|
+
emits=_EMITS,
|
|
181
|
+
tags=["git", "repo", "inspect"],
|
|
182
|
+
)
|
|
183
|
+
async def inspect_repo(self, ctx: Any, payload: dict) -> dict:
|
|
184
|
+
repo = self._repo(payload)
|
|
185
|
+
ctx.emit("git_request", {"operation": "inspect_repo", "repo_path": repo})
|
|
186
|
+
try:
|
|
187
|
+
branch = self._git("rev-parse", "--abbrev-ref", "HEAD", repo=repo)
|
|
188
|
+
head_sha = self._git("rev-parse", "HEAD", repo=repo)
|
|
189
|
+
remotes_raw = self._git("remote", "-v", repo=repo)
|
|
190
|
+
commit_count_raw = self._git("rev-list", "--count", "HEAD", repo=repo)
|
|
191
|
+
except RuntimeError as exc:
|
|
192
|
+
ctx.emit("git_error", {"operation": "inspect_repo", "error": str(exc)})
|
|
193
|
+
raise
|
|
194
|
+
|
|
195
|
+
remotes: list[str] = []
|
|
196
|
+
seen: set[str] = set()
|
|
197
|
+
for line in remotes_raw.splitlines():
|
|
198
|
+
parts = line.split()
|
|
199
|
+
if parts and parts[0] not in seen:
|
|
200
|
+
remotes.append(parts[0])
|
|
201
|
+
seen.add(parts[0])
|
|
202
|
+
|
|
203
|
+
result = {
|
|
204
|
+
"branch": branch,
|
|
205
|
+
"head_sha": head_sha[:40],
|
|
206
|
+
"head_sha7": head_sha[:7],
|
|
207
|
+
"remotes": remotes,
|
|
208
|
+
"commit_count": int(commit_count_raw) if commit_count_raw.isdigit() else 0,
|
|
209
|
+
}
|
|
210
|
+
ctx.emit("git_response", {"operation": "inspect_repo", "branch": result["branch"], "commit_count": result["commit_count"]})
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# log
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
@capability(
|
|
218
|
+
id="chp.adapters.git.log",
|
|
219
|
+
version="0.1.0",
|
|
220
|
+
description="Recent commit list: sha7, author name, subject (truncated), ISO date.",
|
|
221
|
+
category="developer_tooling",
|
|
222
|
+
risk="low",
|
|
223
|
+
input_schema={
|
|
224
|
+
"type": "object",
|
|
225
|
+
"properties": {
|
|
226
|
+
"repo_path": {"type": "string"},
|
|
227
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
|
228
|
+
"branch": {"type": "string"},
|
|
229
|
+
},
|
|
230
|
+
"additionalProperties": False,
|
|
231
|
+
},
|
|
232
|
+
emits=_EMITS,
|
|
233
|
+
tags=["git", "log", "commits"],
|
|
234
|
+
)
|
|
235
|
+
async def log(self, ctx: Any, payload: dict) -> dict:
|
|
236
|
+
repo = self._repo(payload)
|
|
237
|
+
limit = min(int(payload.get("limit") or 20), self._config.max_log_entries)
|
|
238
|
+
branch = payload.get("branch") or "HEAD"
|
|
239
|
+
ctx.emit("git_request", {"operation": "log", "repo_path": repo, "limit": limit, "branch": branch})
|
|
240
|
+
# format: sha|author|date|subject (subject truncated client-side)
|
|
241
|
+
sep = "\x1f"
|
|
242
|
+
fmt = f"%h{sep}%an{sep}%aI{sep}%s"
|
|
243
|
+
try:
|
|
244
|
+
raw = self._git("log", f"-{limit}", f"--format={fmt}", branch, repo=repo)
|
|
245
|
+
except RuntimeError as exc:
|
|
246
|
+
ctx.emit("git_error", {"operation": "log", "error": str(exc)})
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
commits = []
|
|
250
|
+
for line in raw.splitlines():
|
|
251
|
+
parts = line.split(sep, 3)
|
|
252
|
+
if len(parts) == 4:
|
|
253
|
+
sha7, author, date, subject = parts
|
|
254
|
+
commits.append({
|
|
255
|
+
"sha7": sha7,
|
|
256
|
+
"author": author,
|
|
257
|
+
"date": date,
|
|
258
|
+
"subject": subject[:80],
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
ctx.emit("git_response", {"operation": "log", "count": len(commits)})
|
|
262
|
+
return {"commits": commits, "count": len(commits)}
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
# diff_summary
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
@capability(
|
|
269
|
+
id="chp.adapters.git.diff_summary",
|
|
270
|
+
version="0.1.0",
|
|
271
|
+
description="Diff statistics only — files changed, insertions, deletions. Patch text is NEVER returned.",
|
|
272
|
+
category="developer_tooling",
|
|
273
|
+
risk="low",
|
|
274
|
+
input_schema={
|
|
275
|
+
"type": "object",
|
|
276
|
+
"properties": {
|
|
277
|
+
"repo_path": {"type": "string"},
|
|
278
|
+
"base": {"type": "string", "description": "Base ref (e.g. 'main', 'HEAD~1')"},
|
|
279
|
+
"head": {"type": "string", "description": "Head ref (default: working tree)"},
|
|
280
|
+
"staged": {"type": "boolean", "description": "Diff staged changes only"},
|
|
281
|
+
},
|
|
282
|
+
"additionalProperties": False,
|
|
283
|
+
},
|
|
284
|
+
emits=_EMITS,
|
|
285
|
+
tags=["git", "diff"],
|
|
286
|
+
)
|
|
287
|
+
async def diff_summary(self, ctx: Any, payload: dict) -> dict:
|
|
288
|
+
repo = self._repo(payload)
|
|
289
|
+
base = payload.get("base")
|
|
290
|
+
head = payload.get("head")
|
|
291
|
+
staged = bool(payload.get("staged", False))
|
|
292
|
+
ctx.emit("git_request", {"operation": "diff_summary", "repo_path": repo, "staged": staged})
|
|
293
|
+
try:
|
|
294
|
+
args = ["diff", "--stat"]
|
|
295
|
+
if staged:
|
|
296
|
+
args.append("--cached")
|
|
297
|
+
if base:
|
|
298
|
+
args.append(base)
|
|
299
|
+
if head:
|
|
300
|
+
args.append(head)
|
|
301
|
+
stat_output = self._git(*args, repo=repo)
|
|
302
|
+
|
|
303
|
+
# --shortstat for machine-readable counts
|
|
304
|
+
short_args = ["diff", "--shortstat"]
|
|
305
|
+
if staged:
|
|
306
|
+
short_args.append("--cached")
|
|
307
|
+
if base:
|
|
308
|
+
short_args.append(base)
|
|
309
|
+
if head:
|
|
310
|
+
short_args.append(head)
|
|
311
|
+
short = self._git(*short_args, repo=repo)
|
|
312
|
+
except RuntimeError as exc:
|
|
313
|
+
ctx.emit("git_error", {"operation": "diff_summary", "error": str(exc)})
|
|
314
|
+
raise
|
|
315
|
+
|
|
316
|
+
files_changed = insertions = deletions = 0
|
|
317
|
+
if short:
|
|
318
|
+
import re
|
|
319
|
+
m = re.search(r"(\d+) file", short)
|
|
320
|
+
if m:
|
|
321
|
+
files_changed = int(m.group(1))
|
|
322
|
+
m = re.search(r"(\d+) insertion", short)
|
|
323
|
+
if m:
|
|
324
|
+
insertions = int(m.group(1))
|
|
325
|
+
m = re.search(r"(\d+) deletion", short)
|
|
326
|
+
if m:
|
|
327
|
+
deletions = int(m.group(1))
|
|
328
|
+
|
|
329
|
+
# Extract file names from stat output (not content)
|
|
330
|
+
changed_files: list[str] = []
|
|
331
|
+
for line in stat_output.splitlines():
|
|
332
|
+
parts = line.split("|")
|
|
333
|
+
if len(parts) == 2:
|
|
334
|
+
changed_files.append(parts[0].strip())
|
|
335
|
+
|
|
336
|
+
result = {
|
|
337
|
+
"files_changed": files_changed,
|
|
338
|
+
"insertions": insertions,
|
|
339
|
+
"deletions": deletions,
|
|
340
|
+
"changed_files": changed_files,
|
|
341
|
+
}
|
|
342
|
+
ctx.emit("git_response", {"operation": "diff_summary", **result})
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
# precommit_check
|
|
347
|
+
# ------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
@capability(
|
|
350
|
+
id="chp.adapters.git.precommit_check",
|
|
351
|
+
version="0.1.0",
|
|
352
|
+
description="List staged files, count unstaged changes, and flag untracked files.",
|
|
353
|
+
category="developer_tooling",
|
|
354
|
+
risk="low",
|
|
355
|
+
input_schema={
|
|
356
|
+
"type": "object",
|
|
357
|
+
"properties": {
|
|
358
|
+
"repo_path": {"type": "string"},
|
|
359
|
+
},
|
|
360
|
+
"additionalProperties": False,
|
|
361
|
+
},
|
|
362
|
+
emits=_EMITS,
|
|
363
|
+
tags=["git", "precommit", "staged"],
|
|
364
|
+
)
|
|
365
|
+
async def precommit_check(self, ctx: Any, payload: dict) -> dict:
|
|
366
|
+
repo = self._repo(payload)
|
|
367
|
+
ctx.emit("git_request", {"operation": "precommit_check", "repo_path": repo})
|
|
368
|
+
try:
|
|
369
|
+
porcelain = self._git("status", "--porcelain", repo=repo)
|
|
370
|
+
except RuntimeError as exc:
|
|
371
|
+
ctx.emit("git_error", {"operation": "precommit_check", "error": str(exc)})
|
|
372
|
+
raise
|
|
373
|
+
|
|
374
|
+
staged_files: list[str] = []
|
|
375
|
+
unstaged_count = 0
|
|
376
|
+
untracked_count = 0
|
|
377
|
+
for line in porcelain.splitlines():
|
|
378
|
+
if len(line) < 3:
|
|
379
|
+
continue
|
|
380
|
+
x, y, path = line[0], line[1], line[3:]
|
|
381
|
+
if x == "?" and y == "?":
|
|
382
|
+
untracked_count += 1
|
|
383
|
+
else:
|
|
384
|
+
if x not in (" ", "?"):
|
|
385
|
+
staged_files.append(path)
|
|
386
|
+
if y not in (" ", "?"):
|
|
387
|
+
unstaged_count += 1
|
|
388
|
+
|
|
389
|
+
result = {
|
|
390
|
+
"staged_files": staged_files,
|
|
391
|
+
"staged_count": len(staged_files),
|
|
392
|
+
"unstaged_count": unstaged_count,
|
|
393
|
+
"untracked_count": untracked_count,
|
|
394
|
+
"ready_to_commit": len(staged_files) > 0 and unstaged_count == 0,
|
|
395
|
+
}
|
|
396
|
+
ctx.emit("git_response", {"operation": "precommit_check", "staged_count": result["staged_count"], "unstaged_count": unstaged_count})
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
# checkout_branch
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
@capability(
|
|
404
|
+
id="chp.adapters.git.checkout_branch",
|
|
405
|
+
version="0.1.0",
|
|
406
|
+
description="Create a new branch or switch to an existing one.",
|
|
407
|
+
category="developer_tooling",
|
|
408
|
+
risk="medium",
|
|
409
|
+
input_schema={
|
|
410
|
+
"type": "object",
|
|
411
|
+
"properties": {
|
|
412
|
+
"repo_path": {"type": "string"},
|
|
413
|
+
"branch": {"type": "string", "description": "Branch name to create or switch to"},
|
|
414
|
+
"create": {"type": "boolean", "description": "Create the branch if it does not exist (default: true)"},
|
|
415
|
+
},
|
|
416
|
+
"required": ["branch"],
|
|
417
|
+
"additionalProperties": False,
|
|
418
|
+
},
|
|
419
|
+
emits=_EMITS,
|
|
420
|
+
tags=["git", "branch", "checkout"],
|
|
421
|
+
)
|
|
422
|
+
async def checkout_branch(self, ctx: Any, payload: dict) -> dict:
|
|
423
|
+
repo = self._repo(payload)
|
|
424
|
+
branch = payload["branch"]
|
|
425
|
+
create = bool(payload.get("create", True))
|
|
426
|
+
ctx.emit("git_request", {"operation": "checkout_branch", "branch": branch, "create": create})
|
|
427
|
+
try:
|
|
428
|
+
if create:
|
|
429
|
+
# Try switch, fall back to create
|
|
430
|
+
existing = self._git("branch", "--list", branch, repo=repo)
|
|
431
|
+
if existing:
|
|
432
|
+
self._git("checkout", branch, repo=repo)
|
|
433
|
+
else:
|
|
434
|
+
self._git("checkout", "-b", branch, repo=repo)
|
|
435
|
+
else:
|
|
436
|
+
self._git("checkout", branch, repo=repo)
|
|
437
|
+
current = self._git("rev-parse", "--abbrev-ref", "HEAD", repo=repo)
|
|
438
|
+
except RuntimeError as exc:
|
|
439
|
+
ctx.emit("git_error", {"operation": "checkout_branch", "error": str(exc)})
|
|
440
|
+
raise
|
|
441
|
+
|
|
442
|
+
result = {"branch": current, "created": create and current == branch}
|
|
443
|
+
ctx.emit("git_response", {"operation": "checkout_branch", "branch": current})
|
|
444
|
+
return result
|
|
445
|
+
|
|
446
|
+
# ------------------------------------------------------------------
|
|
447
|
+
# commit
|
|
448
|
+
# ------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
@capability(
|
|
451
|
+
id="chp.adapters.git.commit",
|
|
452
|
+
version="0.1.0",
|
|
453
|
+
description="Stage specified files and create a commit. Diff content is never in evidence.",
|
|
454
|
+
category="developer_tooling",
|
|
455
|
+
risk="medium",
|
|
456
|
+
input_schema={
|
|
457
|
+
"type": "object",
|
|
458
|
+
"properties": {
|
|
459
|
+
"repo_path": {"type": "string"},
|
|
460
|
+
"message": {"type": "string", "description": "Commit message"},
|
|
461
|
+
"files": {
|
|
462
|
+
"type": "array",
|
|
463
|
+
"items": {"type": "string"},
|
|
464
|
+
"description": "File paths to stage (relative to repo root). Empty list stages all tracked changes.",
|
|
465
|
+
},
|
|
466
|
+
"allow_empty": {"type": "boolean", "description": "Allow commit with no changes (default: false)"},
|
|
467
|
+
},
|
|
468
|
+
"required": ["message"],
|
|
469
|
+
"additionalProperties": False,
|
|
470
|
+
},
|
|
471
|
+
emits=_EMITS,
|
|
472
|
+
tags=["git", "commit"],
|
|
473
|
+
)
|
|
474
|
+
async def commit(self, ctx: Any, payload: dict) -> dict:
|
|
475
|
+
repo = self._repo(payload)
|
|
476
|
+
message = payload["message"]
|
|
477
|
+
files: list[str] = payload.get("files") or []
|
|
478
|
+
allow_empty = bool(payload.get("allow_empty", False))
|
|
479
|
+
ctx.emit("git_request", {
|
|
480
|
+
"operation": "commit",
|
|
481
|
+
"repo_path": repo,
|
|
482
|
+
"file_count": len(files),
|
|
483
|
+
# message NOT in evidence
|
|
484
|
+
})
|
|
485
|
+
try:
|
|
486
|
+
if files:
|
|
487
|
+
for f in files:
|
|
488
|
+
self._git("add", f, repo=repo)
|
|
489
|
+
else:
|
|
490
|
+
self._git("add", "-u", repo=repo)
|
|
491
|
+
|
|
492
|
+
commit_args = ["commit", "-m", message]
|
|
493
|
+
if allow_empty:
|
|
494
|
+
commit_args.append("--allow-empty")
|
|
495
|
+
self._git(*commit_args, repo=repo)
|
|
496
|
+
sha = self._git("rev-parse", "HEAD", repo=repo)
|
|
497
|
+
sha7 = sha[:7]
|
|
498
|
+
except RuntimeError as exc:
|
|
499
|
+
ctx.emit("git_error", {"operation": "commit", "error": str(exc)})
|
|
500
|
+
raise
|
|
501
|
+
|
|
502
|
+
result = {
|
|
503
|
+
"sha7": sha7,
|
|
504
|
+
"sha": sha[:40],
|
|
505
|
+
"files_staged": len(files),
|
|
506
|
+
}
|
|
507
|
+
ctx.emit("git_response", {"operation": "commit", "sha7": sha7, "files_staged": result["files_staged"]})
|
|
508
|
+
return result
|
|
509
|
+
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
# push
|
|
512
|
+
# ------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
@capability(
|
|
515
|
+
id="chp.adapters.git.push",
|
|
516
|
+
version="0.1.0",
|
|
517
|
+
description="Push a ref to a remote. Evidence: remote, ref, HEAD SHA7, success flag.",
|
|
518
|
+
category="developer_tooling",
|
|
519
|
+
risk="high",
|
|
520
|
+
input_schema={
|
|
521
|
+
"type": "object",
|
|
522
|
+
"properties": {
|
|
523
|
+
"repo_path": {"type": "string"},
|
|
524
|
+
"remote": {"type": "string", "description": "Remote name (e.g. 'origin', 'github')"},
|
|
525
|
+
"ref": {"type": "string", "description": "Ref to push (branch name, tag, or 'HEAD')"},
|
|
526
|
+
"force": {"type": "boolean", "description": "Force push with --force-with-lease (default: false)"},
|
|
527
|
+
"set_upstream": {"type": "boolean", "description": "Set upstream tracking (-u) (default: false)"},
|
|
528
|
+
},
|
|
529
|
+
"required": ["remote", "ref"],
|
|
530
|
+
"additionalProperties": False,
|
|
531
|
+
},
|
|
532
|
+
emits=_EMITS,
|
|
533
|
+
tags=["git", "push", "remote"],
|
|
534
|
+
)
|
|
535
|
+
async def push(self, ctx: Any, payload: dict) -> dict:
|
|
536
|
+
repo = self._repo(payload)
|
|
537
|
+
remote = payload["remote"]
|
|
538
|
+
ref = payload["ref"]
|
|
539
|
+
force = bool(payload.get("force", False))
|
|
540
|
+
set_upstream = bool(payload.get("set_upstream", False))
|
|
541
|
+
|
|
542
|
+
ctx.emit("git_request", {"operation": "push", "remote": remote, "ref": ref})
|
|
543
|
+
try:
|
|
544
|
+
sha = self._git("rev-parse", "HEAD", repo=repo)
|
|
545
|
+
sha7 = sha[:7]
|
|
546
|
+
push_args = ["push", remote, ref]
|
|
547
|
+
if force:
|
|
548
|
+
push_args.append("--force-with-lease")
|
|
549
|
+
if set_upstream:
|
|
550
|
+
push_args.extend(["-u"])
|
|
551
|
+
self._git(*push_args, repo=repo)
|
|
552
|
+
except RuntimeError as exc:
|
|
553
|
+
ctx.emit("git_error", {"operation": "push", "error": str(exc)})
|
|
554
|
+
raise
|
|
555
|
+
|
|
556
|
+
result = {"remote": remote, "ref": ref, "head_sha7": sha7, "success": True}
|
|
557
|
+
ctx.emit("git_response", {"operation": "push", "remote": remote, "ref": ref, "head_sha7": sha7})
|
|
558
|
+
return result
|
|
559
|
+
|
|
560
|
+
# ------------------------------------------------------------------
|
|
561
|
+
# pull
|
|
562
|
+
# ------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
@capability(
|
|
565
|
+
id="chp.adapters.git.pull",
|
|
566
|
+
version="0.1.0",
|
|
567
|
+
description="Pull from a remote. Evidence: remote, branch, new HEAD SHA7, fast_forward flag.",
|
|
568
|
+
category="developer_tooling",
|
|
569
|
+
risk="medium",
|
|
570
|
+
input_schema={
|
|
571
|
+
"type": "object",
|
|
572
|
+
"properties": {
|
|
573
|
+
"repo_path": {"type": "string"},
|
|
574
|
+
"remote": {"type": "string", "description": "Remote name (default: 'origin')"},
|
|
575
|
+
"branch": {"type": "string", "description": "Branch to pull (defaults to current tracking branch)"},
|
|
576
|
+
},
|
|
577
|
+
"additionalProperties": False,
|
|
578
|
+
},
|
|
579
|
+
emits=_EMITS,
|
|
580
|
+
tags=["git", "pull", "remote"],
|
|
581
|
+
)
|
|
582
|
+
async def pull(self, ctx: Any, payload: dict) -> dict:
|
|
583
|
+
repo = self._repo(payload)
|
|
584
|
+
remote = payload.get("remote", "origin")
|
|
585
|
+
branch: str | None = payload.get("branch")
|
|
586
|
+
|
|
587
|
+
ctx.emit("git_request", {"operation": "pull", "remote": remote, "branch": branch})
|
|
588
|
+
try:
|
|
589
|
+
pull_args = ["pull", remote]
|
|
590
|
+
if branch:
|
|
591
|
+
pull_args.append(branch)
|
|
592
|
+
output = self._git(*pull_args, repo=repo)
|
|
593
|
+
sha = self._git("rev-parse", "HEAD", repo=repo)
|
|
594
|
+
sha7 = sha[:7]
|
|
595
|
+
except RuntimeError as exc:
|
|
596
|
+
ctx.emit("git_error", {"operation": "pull", "error": str(exc)})
|
|
597
|
+
raise
|
|
598
|
+
|
|
599
|
+
fast_forward = "Fast-forward" in output
|
|
600
|
+
already_up_to_date = "Already up to date" in output or "Already up-to-date" in output
|
|
601
|
+
result = {
|
|
602
|
+
"remote": remote,
|
|
603
|
+
"branch": branch,
|
|
604
|
+
"head_sha7": sha7,
|
|
605
|
+
"fast_forward": fast_forward,
|
|
606
|
+
"already_up_to_date": already_up_to_date,
|
|
607
|
+
}
|
|
608
|
+
ctx.emit("git_response", {
|
|
609
|
+
"operation": "pull",
|
|
610
|
+
"remote": remote,
|
|
611
|
+
"head_sha7": sha7,
|
|
612
|
+
"fast_forward": fast_forward,
|
|
613
|
+
"already_up_to_date": already_up_to_date,
|
|
614
|
+
})
|
|
615
|
+
return result
|
|
616
|
+
|
|
617
|
+
# ------------------------------------------------------------------
|
|
618
|
+
# merge
|
|
619
|
+
# ------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
@capability(
|
|
622
|
+
id="chp.adapters.git.merge",
|
|
623
|
+
version="0.1.0",
|
|
624
|
+
description="Merge a branch into the current branch. Evidence: branch, strategy, HEAD SHA7, conflicts flag.",
|
|
625
|
+
category="developer_tooling",
|
|
626
|
+
risk="high",
|
|
627
|
+
input_schema={
|
|
628
|
+
"type": "object",
|
|
629
|
+
"properties": {
|
|
630
|
+
"repo_path": {"type": "string"},
|
|
631
|
+
"branch": {"type": "string", "description": "Branch to merge"},
|
|
632
|
+
"strategy": {
|
|
633
|
+
"type": "string",
|
|
634
|
+
"enum": ["default", "squash", "no-ff"],
|
|
635
|
+
"description": "Merge strategy (default: 'default')",
|
|
636
|
+
},
|
|
637
|
+
"message": {"type": "string", "description": "Commit message for the merge (NOT in evidence)"},
|
|
638
|
+
},
|
|
639
|
+
"required": ["branch"],
|
|
640
|
+
"additionalProperties": False,
|
|
641
|
+
},
|
|
642
|
+
emits=_EMITS,
|
|
643
|
+
tags=["git", "merge"],
|
|
644
|
+
)
|
|
645
|
+
async def merge(self, ctx: Any, payload: dict) -> dict:
|
|
646
|
+
repo = self._repo(payload)
|
|
647
|
+
branch = payload["branch"]
|
|
648
|
+
strategy = payload.get("strategy", "default")
|
|
649
|
+
message: str | None = payload.get("message")
|
|
650
|
+
|
|
651
|
+
ctx.emit("git_request", {"operation": "merge", "branch": branch, "strategy": strategy})
|
|
652
|
+
conflicts = False
|
|
653
|
+
try:
|
|
654
|
+
merge_args = ["merge"]
|
|
655
|
+
if strategy == "squash":
|
|
656
|
+
merge_args.append("--squash")
|
|
657
|
+
elif strategy == "no-ff":
|
|
658
|
+
merge_args.append("--no-ff")
|
|
659
|
+
if message:
|
|
660
|
+
merge_args.extend(["-m", message])
|
|
661
|
+
merge_args.append(branch)
|
|
662
|
+
self._git(*merge_args, repo=repo)
|
|
663
|
+
sha = self._git("rev-parse", "HEAD", repo=repo)
|
|
664
|
+
sha7 = sha[:7]
|
|
665
|
+
except RuntimeError as exc:
|
|
666
|
+
error_str = str(exc)
|
|
667
|
+
if "CONFLICT" in error_str or "conflict" in error_str.lower():
|
|
668
|
+
conflicts = True
|
|
669
|
+
ctx.emit("git_error", {"operation": "merge", "error": error_str, "conflicts": conflicts})
|
|
670
|
+
raise
|
|
671
|
+
|
|
672
|
+
result = {
|
|
673
|
+
"branch": branch,
|
|
674
|
+
"strategy": strategy,
|
|
675
|
+
"head_sha7": sha7,
|
|
676
|
+
"conflicts": conflicts,
|
|
677
|
+
}
|
|
678
|
+
ctx.emit("git_response", {
|
|
679
|
+
"operation": "merge",
|
|
680
|
+
"branch": branch,
|
|
681
|
+
"strategy": strategy,
|
|
682
|
+
"head_sha7": sha7,
|
|
683
|
+
})
|
|
684
|
+
return result
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-git
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: CHP capability adapter — Git repository inspection and operations
|
|
5
|
+
Author: Auxo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: adapter,capability-host-protocol,chp,git,version-control
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: chp-core>=0.7.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# chp-adapter-git
|
|
25
|
+
|
|
26
|
+
CHP capability adapter for local Git repository inspection and operations.
|
|
27
|
+
|
|
28
|
+
## Capabilities
|
|
29
|
+
|
|
30
|
+
| Capability ID | Risk | Description |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `chp.adapters.git.status` | low | Working tree status: branch, staged/unstaged/untracked counts |
|
|
33
|
+
| `chp.adapters.git.inspect_repo` | low | Repository overview: branch, HEAD SHA, remotes, commit count |
|
|
34
|
+
| `chp.adapters.git.log` | low | Recent commits list (sha7, author, subject, date) |
|
|
35
|
+
| `chp.adapters.git.diff_summary` | low | Diff statistics: files changed, insertions, deletions |
|
|
36
|
+
| `chp.adapters.git.precommit_check` | low | Staged file list + unstaged/untracked counts |
|
|
37
|
+
| `chp.adapters.git.checkout_branch` | medium | Create or switch to a branch |
|
|
38
|
+
| `chp.adapters.git.commit` | medium | Stage files and commit with a message |
|
|
39
|
+
|
|
40
|
+
## Evidence Hygiene
|
|
41
|
+
|
|
42
|
+
- Diff content (patch text): **never** in evidence
|
|
43
|
+
- File content: **never** in evidence
|
|
44
|
+
- Commit messages: **not** in evidence (only sha7/count in events)
|
|
45
|
+
- Commit subjects truncated to 80 chars in `log` output
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from chp_core import LocalCapabilityHost, SQLiteEvidenceStore
|
|
51
|
+
from chp_adapter_git import GitAdapter, GitConfig
|
|
52
|
+
|
|
53
|
+
store = SQLiteEvidenceStore("evidence.db")
|
|
54
|
+
host = LocalCapabilityHost(store=store)
|
|
55
|
+
host.register_adapter(GitAdapter(config=GitConfig(default_repo_path="/path/to/repo")))
|
|
56
|
+
|
|
57
|
+
result = await host.ainvoke("chp.adapters.git.status", {})
|
|
58
|
+
print(result.output) # {"branch": "main", "staged": 0, "unstaged": 1, "untracked": 2, "clean": False}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Testing with Injectable Backend
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
class FakeGitBackend:
|
|
65
|
+
def run(self, *args, cwd=None):
|
|
66
|
+
return {"rev-parse --abbrev-ref HEAD": "main"}.get(" ".join(args), "")
|
|
67
|
+
|
|
68
|
+
adapter = GitAdapter(config=GitConfig(backend=FakeGitBackend()))
|
|
69
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
chp_adapter_git/__init__.py,sha256=AtSUAQ8ZaGKNZiln-5lGoIb1binEaZKzwZs1OYztujk,173
|
|
2
|
+
chp_adapter_git/adapter.py,sha256=d1T8i0_IYJwSR_wAgHhGPYzg72nClEy825QpMVSE128,26295
|
|
3
|
+
chp_adapter_git-0.8.0.dist-info/METADATA,sha256=s7oDUeumumAzckpyl2QAXuxk_qnIMysfO_QvDEDDOFQ,2685
|
|
4
|
+
chp_adapter_git-0.8.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
chp_adapter_git-0.8.0.dist-info/entry_points.txt,sha256=urmUoEqjMU9PfPswZNjLfmxPUy-tqYeSUZsiB_T0a2o,48
|
|
6
|
+
chp_adapter_git-0.8.0.dist-info/RECORD,,
|