pulse-coding-agent 0.1.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.
Files changed (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/edits.py ADDED
@@ -0,0 +1,65 @@
1
+ """Approval-gated file editing, independent from any user interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from difflib import unified_diff
8
+
9
+ from pulse.sandbox import ProjectSandbox
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class EditProposal:
14
+ file_path: str
15
+ before_content: str | None
16
+ after_content: str
17
+ reason: str
18
+ unified_diff: str
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class EditResult:
23
+ proposal: EditProposal
24
+ applied: bool
25
+
26
+
27
+ ApprovalHandler = Callable[[EditProposal], Awaitable[bool]]
28
+
29
+
30
+ class EditWorkflow:
31
+ """Creates diffs first and mutates a project only after explicit approval."""
32
+
33
+ def __init__(self, sandbox: ProjectSandbox) -> None:
34
+ self.sandbox = sandbox
35
+
36
+ async def propose(self, file_path: str, content: str, reason: str) -> EditProposal:
37
+ before = self.sandbox.read_file_for_edit(file_path)
38
+ return EditProposal(
39
+ file_path=file_path,
40
+ before_content=before,
41
+ after_content=content,
42
+ reason=reason,
43
+ unified_diff="".join(
44
+ unified_diff(
45
+ (before or "").splitlines(keepends=True),
46
+ content.splitlines(keepends=True),
47
+ fromfile=f"a/{file_path}",
48
+ tofile=f"b/{file_path}",
49
+ )
50
+ ),
51
+ )
52
+
53
+ async def request_and_apply(
54
+ self, file_path: str, content: str, reason: str, approve: ApprovalHandler
55
+ ) -> EditResult:
56
+ proposal = await self.propose(file_path, content, reason)
57
+ if not await approve(proposal):
58
+ self.sandbox.record_rejected_edit(proposal.file_path, proposal.reason)
59
+ return EditResult(proposal=proposal, applied=False)
60
+
61
+ self.sandbox.apply_approved_edit(proposal.file_path, proposal.after_content, proposal.reason)
62
+ return EditResult(proposal=proposal, applied=True)
63
+
64
+ async def rollback_last(self) -> bool:
65
+ return self.sandbox.rollback_last_approved_edit()
pulse/episodic.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+ from pulse.storage import migrate_database
9
+
10
+ EPISODIC_SCHEMA_VERSION = 1
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ExecutionTrace:
15
+ id: int
16
+ timestamp: str
17
+ prompt: str
18
+ error: str
19
+ resolution: str
20
+
21
+
22
+ class EpisodicMemory:
23
+ """Manages execution trace storage (Prompt, Error, Resolution) in workspace SQLite database."""
24
+
25
+ def __init__(self, db_path: Path | None = None) -> None:
26
+ self.db_path = db_path or Path(".agent/episodic-memory.sqlite3")
27
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
28
+ self._init_db()
29
+
30
+ def _init_db(self) -> None:
31
+ def migration(conn: sqlite3.Connection, _current: int) -> None:
32
+ conn.execute(
33
+ """
34
+ CREATE TABLE IF NOT EXISTS execution_traces (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ timestamp TEXT NOT NULL,
37
+ prompt TEXT NOT NULL,
38
+ error TEXT NOT NULL,
39
+ resolution TEXT NOT NULL
40
+ )
41
+ """
42
+ )
43
+ migrate_database(self.db_path, EPISODIC_SCHEMA_VERSION, migration)
44
+
45
+ def log_trace(self, prompt: str, error: str, resolution: str) -> ExecutionTrace:
46
+ timestamp = datetime.now(UTC).isoformat()
47
+ with sqlite3.connect(self.db_path) as conn:
48
+ cursor = conn.cursor()
49
+ cursor.execute(
50
+ "INSERT INTO execution_traces (timestamp, prompt, error, resolution) VALUES (?, ?, ?, ?)",
51
+ (timestamp, prompt, error, resolution),
52
+ )
53
+ conn.commit()
54
+ trace_id = cursor.lastrowid or 0
55
+
56
+ return ExecutionTrace(
57
+ id=trace_id,
58
+ timestamp=timestamp,
59
+ prompt=prompt,
60
+ error=error,
61
+ resolution=resolution,
62
+ )
63
+
64
+ def search_similar_resolutions(self, query: str, limit: int = 5) -> list[ExecutionTrace]:
65
+ query_lower = f"%{query.lower()}%"
66
+ with sqlite3.connect(self.db_path) as conn:
67
+ cursor = conn.cursor()
68
+ cursor.execute(
69
+ """
70
+ SELECT id, timestamp, prompt, error, resolution
71
+ FROM execution_traces
72
+ WHERE LOWER(prompt) LIKE ? OR LOWER(error) LIKE ? OR LOWER(resolution) LIKE ?
73
+ ORDER BY id DESC
74
+ LIMIT ?
75
+ """,
76
+ (query_lower, query_lower, query_lower, limit),
77
+ )
78
+ rows = cursor.fetchall()
79
+
80
+ return [
81
+ ExecutionTrace(id=row[0], timestamp=row[1], prompt=row[2], error=row[3], resolution=row[4])
82
+ for row in rows
83
+ ]
84
+
85
+ def get_all_traces(self) -> list[ExecutionTrace]:
86
+ with sqlite3.connect(self.db_path) as conn:
87
+ cursor = conn.cursor()
88
+ cursor.execute("SELECT id, timestamp, prompt, error, resolution FROM execution_traces ORDER BY id ASC")
89
+ rows = cursor.fetchall()
90
+ return [
91
+ ExecutionTrace(id=row[0], timestamp=row[1], prompt=row[2], error=row[3], resolution=row[4])
92
+ for row in rows
93
+ ]
pulse/eval/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Evaluation utilities for Pulse.
2
+ Provides:
3
+ - PatchVerifier: Apply candidate diffs and evaluate test outcomes.
4
+ - TrajectoryLogger: Record agent action trajectories.
5
+ """
6
+
7
+ from .trajectory_logger import TrajectoryLogger, TrajectoryStep # noqa: F401
8
+ from .verifier import PatchVerifier # noqa: F401
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import asdict, dataclass, field
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class TrajectoryStep:
14
+ """A single recorded step of an agent run."""
15
+
16
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
17
+ prompt: str | None = None
18
+ tool_name: str | None = None
19
+ tool_args: Mapping[str, Any] | None = None
20
+ reasoning: str | None = None
21
+ token_cost: int | None = None
22
+ test_log: str | None = None
23
+ result: str | None = None
24
+
25
+ class TrajectoryLogger:
26
+ """Collects and persists a sequence of :class:`TrajectoryStep` objects.
27
+ Stored under ``.agent/trajectories/<task_id>.json``.
28
+ """
29
+
30
+ def __init__(self, workspace: Path, task_id: str | None = None) -> None:
31
+ self.workspace = workspace.resolve()
32
+ self.task_id = task_id or uuid.uuid4().hex
33
+ self._steps: list[TrajectoryStep] = []
34
+ self._base = self.workspace / ".agent" / "trajectories"
35
+ self._base.mkdir(parents=True, exist_ok=True)
36
+
37
+ # ------------------------------------------------------------------ API
38
+ def add_step(
39
+ self,
40
+ *,
41
+ prompt: str | None = None,
42
+ tool_name: str | None = None,
43
+ tool_args: Mapping[str, Any] | None = None,
44
+ reasoning: str | None = None,
45
+ token_cost: int | None = None,
46
+ test_log: str | None = None,
47
+ result: str | None = None,
48
+ ) -> None:
49
+ """Append a new step to the internal buffer."""
50
+ step = TrajectoryStep(
51
+ prompt=prompt,
52
+ tool_name=tool_name,
53
+ tool_args=tool_args,
54
+ reasoning=reasoning,
55
+ token_cost=token_cost,
56
+ test_log=test_log,
57
+ result=result,
58
+ )
59
+ self._steps.append(step)
60
+
61
+ def dump(self) -> Path:
62
+ """Write the buffered steps to ``.agent/trajectories/<task_id>.json``.
63
+ Returns the path to the written file.
64
+ """
65
+ out_path = self._base / f"{self.task_id}.json"
66
+ payload = {
67
+ "schema_version": 1,
68
+ "task_id": self.task_id,
69
+ "steps": [asdict(step) for step in self._steps],
70
+ }
71
+ out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
72
+ return out_path
73
+
74
+ def load(self) -> Sequence[TrajectoryStep]:
75
+ """Load a previously persisted trajectory (if it exists) and replace the buffer.
76
+ Returns the loaded list of steps.
77
+ """
78
+ path = self._base / f"{self.task_id}.json"
79
+ if not path.is_file():
80
+ self._steps = []
81
+ return []
82
+ payload = json.loads(path.read_text(encoding="utf-8"))
83
+ raw: list[dict[str, Any]] = payload if isinstance(payload, list) else payload["steps"]
84
+ self._steps = [TrajectoryStep(**item) for item in raw]
85
+ return self._steps
86
+
87
+ # ------------------------------------------------------------------ Helpers
88
+ @property
89
+ def steps(self) -> Sequence[TrajectoryStep]:
90
+ """Read‑only view of the current steps buffer."""
91
+ return tuple(self._steps)
pulse/eval/verifier.py ADDED
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Literal, TypedDict
11
+
12
+ from pulse.subprocesses import isolated_process_kwargs
13
+
14
+ TestResult = Literal["pass", "fail"]
15
+
16
+ class PatchVerificationMetrics(TypedDict):
17
+ """Outcome metrics for a candidate fix."""
18
+ issue_description: str
19
+ patch: str
20
+ initial_test_result: TestResult
21
+ post_apply_test_result: TestResult
22
+ fail_to_pass: bool
23
+ pass_to_pass: bool
24
+ pytest_stdout: str
25
+ pytest_stderr: str
26
+
27
+ @dataclass(slots=True)
28
+ class PatchVerifier:
29
+ """Applies a diff to a copy of the repository and evaluates test outcomes."""
30
+
31
+ workspace: Path
32
+ """Root of the original Pulse project."""
33
+
34
+ def _run_pytest(self, directory: Path) -> tuple[int, str, str]:
35
+ """Execute ``pytest`` in *directory* and return (returncode, stdout, stderr)."""
36
+ try:
37
+ proc = subprocess.run(
38
+ [sys.executable, "-m", "pytest", "-p", "no:cacheprovider"],
39
+ cwd=directory,
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=120,
43
+ check=False,
44
+ env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
45
+ **isolated_process_kwargs(),
46
+ )
47
+ return proc.returncode, proc.stdout, proc.stderr
48
+ except subprocess.TimeoutExpired as exc:
49
+ return -1, "", f"Timeout: {exc}"
50
+
51
+ def _apply_patch(self, target_dir: Path, patch_text: str) -> tuple[bool, str]:
52
+ """Validate and apply a unified diff without allowing paths outside the copy."""
53
+ if not patch_text:
54
+ return False, "Patch is empty."
55
+ try:
56
+ normalized_patch = patch_text if patch_text.endswith("\n") else patch_text + "\n"
57
+ for mode in ("--check", "--apply"):
58
+ proc = subprocess.run(
59
+ ["git", "apply", mode, "--whitespace=nowarn", "-"],
60
+ cwd=target_dir,
61
+ input=normalized_patch,
62
+ capture_output=True,
63
+ text=True,
64
+ timeout=30,
65
+ check=False,
66
+ **isolated_process_kwargs(),
67
+ )
68
+ if proc.returncode != 0:
69
+ detail = proc.stderr.strip() or proc.stdout.strip() or "git apply failed"
70
+ return False, detail
71
+ return True, ""
72
+ except (OSError, subprocess.TimeoutExpired) as exc:
73
+ return False, f"Patch application failed: {exc}"
74
+
75
+ def verify(self, issue_description: str, patch: str) -> PatchVerificationMetrics:
76
+ """Run the verification cycle and return detailed metrics.
77
+ Steps:
78
+ 1. Run pytest on the original workspace (baseline).
79
+ 2. Copy the workspace to a temporary isolated directory.
80
+ 3. Apply the supplied *patch* in the temporary copy.
81
+ 4. Re‑run pytest on the patched copy.
82
+ 5. Compute pass/fail transitions and return the metrics dict.
83
+ """
84
+ # Baseline run
85
+ init_rc, _init_out, _init_err = self._run_pytest(self.workspace)
86
+ initial_result: TestResult = "pass" if init_rc == 0 else "fail"
87
+
88
+ # Isolated copy and patch application
89
+ with tempfile.TemporaryDirectory() as tmp_dir:
90
+ tmp_path = Path(tmp_dir)
91
+ ignored = {
92
+ ".git",
93
+ ".mypy_cache",
94
+ ".pytest_cache",
95
+ ".ruff_cache",
96
+ ".venv",
97
+ "__pycache__",
98
+ "build",
99
+ "dist",
100
+ "venv",
101
+ }
102
+ for item in self.workspace.iterdir():
103
+ if item.name in ignored:
104
+ continue
105
+ dest = tmp_path / item.name
106
+ if item.is_dir():
107
+ shutil.copytree(
108
+ item,
109
+ dest,
110
+ dirs_exist_ok=True,
111
+ ignore=shutil.ignore_patterns(*ignored),
112
+ )
113
+ else:
114
+ shutil.copy2(item, dest)
115
+
116
+ applied, apply_error = self._apply_patch(tmp_path, patch)
117
+ if applied:
118
+ post_rc, post_out, post_err = self._run_pytest(tmp_path)
119
+ else:
120
+ post_rc, post_out, post_err = -1, "", apply_error
121
+
122
+ post_result: TestResult = "pass" if post_rc == 0 else "fail"
123
+
124
+ return {
125
+ "issue_description": issue_description,
126
+ "patch": patch,
127
+ "initial_test_result": initial_result,
128
+ "post_apply_test_result": post_result,
129
+ "fail_to_pass": initial_result == "fail" and post_result == "pass",
130
+ "pass_to_pass": initial_result == "pass" and post_result == "pass",
131
+ "pytest_stdout": post_out,
132
+ "pytest_stderr": post_err,
133
+ }
@@ -0,0 +1,5 @@
1
+ """Execution adapters that bind durable tasks to isolated backends."""
2
+
3
+ from pulse.execution.remote_task import RemoteTaskExecutor
4
+
5
+ __all__ = ["RemoteTaskExecutor"]
@@ -0,0 +1,76 @@
1
+ """Fenced bridge between durable tasks and the remote sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pulse.sandbox.api import Sandbox
11
+ from pulse.sandbox.process import ProcessResult
12
+ from pulse.task_manager import Task, TaskManager
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class RemoteTaskExecutor:
17
+ """Submit one task attempt to a remote sandbox using its durable ID.
18
+
19
+ This adapter is deliberately outside both ``TaskManager`` and the sandbox:
20
+ the task layer owns fencing and recovery while the sandbox owns execution
21
+ policy. It is the sole bridge responsible for carrying the fenced
22
+ ``remote_execution_id`` across that boundary.
23
+ """
24
+
25
+ task_manager: TaskManager
26
+ sandbox: Sandbox
27
+
28
+ async def execute(
29
+ self,
30
+ task_id: str,
31
+ command: str | list[str],
32
+ *,
33
+ cwd: Path | str | None = None,
34
+ env: dict[str, str] | None = None,
35
+ ) -> Task:
36
+ await self.sandbox.initialize()
37
+ if getattr(self.sandbox.backend, "name", None) != "remote":
38
+ raise RuntimeError(
39
+ "RemoteTaskExecutor requires a configured remote sandbox backend."
40
+ )
41
+
42
+ task = await self.task_manager.start_task(task_id)
43
+ execution_id = await self.task_manager.remote_execution_id_for_task(task.id)
44
+ try:
45
+ result = await self.sandbox.execute_command(
46
+ command,
47
+ cwd=cwd,
48
+ env=env,
49
+ execution_id=execution_id,
50
+ )
51
+ except Exception as err: # noqa: BLE001 - persists a recoverable task failure
52
+ return await self.task_manager.fail_task(task.id, str(err))
53
+
54
+ if self._succeeded(result):
55
+ return await self.task_manager.complete_task(
56
+ task.id, json.dumps(self._result_payload(result), sort_keys=True)
57
+ )
58
+ return await self.task_manager.fail_task(
59
+ task.id,
60
+ result.stderr or f"Remote command exited with code {result.exit_code}.",
61
+ )
62
+
63
+ @staticmethod
64
+ def _succeeded(result: ProcessResult) -> bool:
65
+ return result.exit_code == 0 and not result.timed_out
66
+
67
+ @staticmethod
68
+ def _result_payload(result: ProcessResult) -> dict[str, Any]:
69
+ return {
70
+ "exit_code": result.exit_code,
71
+ "stdout": result.stdout,
72
+ "stderr": result.stderr,
73
+ "duration_ms": result.duration_ms,
74
+ "timed_out": result.timed_out,
75
+ "termination_reason": result.termination_reason,
76
+ }
pulse/git.py ADDED
@@ -0,0 +1,162 @@
1
+ """Async Git inspection and commit guidance, independent of Pulse interfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from pulse.subprocesses import (
12
+ isolated_process_kwargs,
13
+ isolated_subprocess_environment,
14
+ terminate_process,
15
+ )
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class GitChange:
20
+ path: str
21
+ index_status: str
22
+ worktree_status: str
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class GitStatus:
27
+ is_repository: bool
28
+ branch: str | None = None
29
+ head: str | None = None
30
+ changes: tuple[GitChange, ...] = ()
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class DiffAnalysis:
35
+ files_changed: int
36
+ additions: int
37
+ deletions: int
38
+ files: tuple[str, ...] = ()
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class GitInsight:
43
+ status: GitStatus
44
+ diff: DiffAnalysis
45
+ commit_suggestion: str | None
46
+
47
+
48
+ GitRunner = Callable[[tuple[str, ...], Path], Awaitable[tuple[int, str, str]]]
49
+
50
+
51
+ class GitIntelligence:
52
+ """Read-only Git state, diff analysis, and conventional commit suggestions."""
53
+
54
+ def __init__(self, workspace: Path, *, runner: GitRunner | None = None) -> None:
55
+ self.workspace = workspace.resolve()
56
+ self._runner = runner or self._run_git
57
+
58
+ async def status(self) -> GitStatus:
59
+ code, output, _ = await self._runner(("rev-parse", "--is-inside-work-tree"), self.workspace)
60
+ if code != 0 or output.strip() != "true":
61
+ return GitStatus(False)
62
+ _, branch, _ = await self._runner(("branch", "--show-current"), self.workspace)
63
+ _, head, _ = await self._runner(("rev-parse", "--short", "HEAD"), self.workspace)
64
+ _, porcelain, _ = await self._runner(("status", "--porcelain=v1"), self.workspace)
65
+ return GitStatus(True, branch.strip() or None, head.strip() or None, self._parse_status(porcelain))
66
+
67
+ async def analyze_diff(self) -> DiffAnalysis:
68
+ status = await self.status()
69
+ if not status.is_repository:
70
+ return DiffAnalysis(0, 0, 0)
71
+ _, unstaged, _ = await self._runner(
72
+ ("diff", "--no-ext-diff", "--no-textconv", "--numstat"), self.workspace
73
+ )
74
+ _, staged, _ = await self._runner(
75
+ ("diff", "--cached", "--no-ext-diff", "--no-textconv", "--numstat"),
76
+ self.workspace,
77
+ )
78
+ additions = deletions = 0
79
+ files = {change.path for change in status.changes}
80
+ for line in f"{unstaged}\n{staged}".splitlines():
81
+ parts = line.split("\t", 2)
82
+ if len(parts) != 3:
83
+ continue
84
+ added, removed, path = parts
85
+ additions += int(added) if added.isdigit() else 0
86
+ deletions += int(removed) if removed.isdigit() else 0
87
+ files.add(path)
88
+ return DiffAnalysis(len(files), additions, deletions, tuple(sorted(files)))
89
+
90
+ async def inspect(self) -> GitInsight:
91
+ """Return branch, working-tree changes, diff totals, and a commit idea."""
92
+ status = await self.status()
93
+ if not status.is_repository:
94
+ return GitInsight(status, DiffAnalysis(0, 0, 0), None)
95
+ # Reuse the status we already collected while keeping diff collection
96
+ # independent and safe for callers that only need one of the operations.
97
+ _, unstaged, _ = await self._runner(
98
+ ("diff", "--no-ext-diff", "--no-textconv", "--numstat"), self.workspace
99
+ )
100
+ _, staged, _ = await self._runner(
101
+ ("diff", "--cached", "--no-ext-diff", "--no-textconv", "--numstat"),
102
+ self.workspace,
103
+ )
104
+ additions = deletions = 0
105
+ files = {change.path for change in status.changes}
106
+ for line in f"{unstaged}\n{staged}".splitlines():
107
+ parts = line.split("\t", 2)
108
+ if len(parts) == 3:
109
+ additions += int(parts[0]) if parts[0].isdigit() else 0
110
+ deletions += int(parts[1]) if parts[1].isdigit() else 0
111
+ files.add(parts[2])
112
+ diff = DiffAnalysis(len(files), additions, deletions, tuple(sorted(files)))
113
+ return GitInsight(status, diff, self.suggest_commit(status, diff))
114
+
115
+ @staticmethod
116
+ def suggest_commit(status: GitStatus, diff: DiffAnalysis) -> str | None:
117
+ if not status.is_repository or not diff.files:
118
+ return None
119
+ paths = diff.files
120
+ if all(path.lower().endswith((".md", ".rst")) or "docs/" in path.lower() for path in paths):
121
+ kind = "docs"
122
+ elif all("test" in Path(path).name.lower() for path in paths):
123
+ kind = "test"
124
+ elif any(change.index_status == "A" or change.worktree_status == "?" for change in status.changes):
125
+ kind = "feat"
126
+ else:
127
+ kind = "chore"
128
+ subject = paths[0] if len(paths) == 1 else f"{len(paths)} files"
129
+ return f"{kind}: update {subject}"
130
+
131
+ @staticmethod
132
+ def _parse_status(output: str) -> tuple[GitChange, ...]:
133
+ changes: list[GitChange] = []
134
+ for line in output.splitlines():
135
+ if len(line) < 4:
136
+ continue
137
+ changes.append(GitChange(line[3:], line[0], line[1]))
138
+ return tuple(changes)
139
+
140
+ @staticmethod
141
+ async def _run_git(arguments: tuple[str, ...], workspace: Path) -> tuple[int, str, str]:
142
+ process: asyncio.subprocess.Process | None = None
143
+ try:
144
+ process = await asyncio.create_subprocess_exec(
145
+ "git", *arguments, cwd=workspace, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
146
+ env=isolated_subprocess_environment(
147
+ {
148
+ "GIT_CONFIG_NOSYSTEM": "1",
149
+ "GIT_CONFIG_GLOBAL": str(Path(os.devnull)),
150
+ "GIT_ATTR_NOSYSTEM": "1",
151
+ }
152
+ ),
153
+ **isolated_process_kwargs(),
154
+ )
155
+ except OSError as error:
156
+ return 127, "", str(error)
157
+ try:
158
+ stdout, stderr = await process.communicate()
159
+ except asyncio.CancelledError:
160
+ await terminate_process(process)
161
+ raise
162
+ return process.returncode, stdout.decode(errors="replace"), stderr.decode(errors="replace")