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,8 @@
1
+ from .adapter import GitAdapter, GitConfig, GitBackend, SubprocessGitBackend
2
+
3
+ __all__ = [
4
+ "GitAdapter",
5
+ "GitConfig",
6
+ "GitBackend",
7
+ "SubprocessGitBackend",
8
+ ]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [chp.adapters]
2
+ git = chp_adapter_git:GitAdapter