git-aware-coding-agent 1.0.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.
- avos_cli/__init__.py +3 -0
- avos_cli/agents/avos_ask_agent.md +47 -0
- avos_cli/agents/avos_ask_agent_JSON_converter.md +78 -0
- avos_cli/agents/avos_hisotry_agent_JSON_converter.md +92 -0
- avos_cli/agents/avos_history_agent.md +58 -0
- avos_cli/agents/git_diff_agent.md +63 -0
- avos_cli/artifacts/__init__.py +17 -0
- avos_cli/artifacts/base.py +47 -0
- avos_cli/artifacts/commit_builder.py +35 -0
- avos_cli/artifacts/doc_builder.py +30 -0
- avos_cli/artifacts/issue_builder.py +37 -0
- avos_cli/artifacts/pr_builder.py +50 -0
- avos_cli/cli/__init__.py +1 -0
- avos_cli/cli/main.py +504 -0
- avos_cli/commands/__init__.py +1 -0
- avos_cli/commands/ask.py +541 -0
- avos_cli/commands/connect.py +363 -0
- avos_cli/commands/history.py +549 -0
- avos_cli/commands/hook_install.py +260 -0
- avos_cli/commands/hook_sync.py +231 -0
- avos_cli/commands/ingest.py +506 -0
- avos_cli/commands/ingest_pr.py +239 -0
- avos_cli/config/__init__.py +1 -0
- avos_cli/config/hash_store.py +93 -0
- avos_cli/config/lock.py +122 -0
- avos_cli/config/manager.py +180 -0
- avos_cli/config/state.py +90 -0
- avos_cli/exceptions.py +272 -0
- avos_cli/models/__init__.py +58 -0
- avos_cli/models/api.py +75 -0
- avos_cli/models/artifacts.py +99 -0
- avos_cli/models/config.py +56 -0
- avos_cli/models/diff.py +117 -0
- avos_cli/models/query.py +234 -0
- avos_cli/parsers/__init__.py +21 -0
- avos_cli/parsers/artifact_ref_extractor.py +173 -0
- avos_cli/parsers/reference_parser.py +117 -0
- avos_cli/services/__init__.py +1 -0
- avos_cli/services/chronology_service.py +68 -0
- avos_cli/services/citation_validator.py +134 -0
- avos_cli/services/context_budget_service.py +104 -0
- avos_cli/services/diff_resolver.py +398 -0
- avos_cli/services/diff_summary_service.py +141 -0
- avos_cli/services/git_client.py +351 -0
- avos_cli/services/github_client.py +443 -0
- avos_cli/services/llm_client.py +312 -0
- avos_cli/services/memory_client.py +323 -0
- avos_cli/services/query_fallback_formatter.py +108 -0
- avos_cli/services/reply_output_service.py +341 -0
- avos_cli/services/sanitization_service.py +218 -0
- avos_cli/utils/__init__.py +1 -0
- avos_cli/utils/dotenv_load.py +50 -0
- avos_cli/utils/hashing.py +22 -0
- avos_cli/utils/logger.py +77 -0
- avos_cli/utils/output.py +232 -0
- avos_cli/utils/sanitization_diagnostics.py +81 -0
- avos_cli/utils/time_helpers.py +56 -0
- git_aware_coding_agent-1.0.0.dist-info/METADATA +390 -0
- git_aware_coding_agent-1.0.0.dist-info/RECORD +62 -0
- git_aware_coding_agent-1.0.0.dist-info/WHEEL +4 -0
- git_aware_coding_agent-1.0.0.dist-info/entry_points.txt +2 -0
- git_aware_coding_agent-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Diff summary service for git diff analysis.
|
|
2
|
+
|
|
3
|
+
Uses the REPLY_MODEL to summarize git diffs via the git_diff_agent.md
|
|
4
|
+
prompt template. Summaries are held in-memory only and discarded after
|
|
5
|
+
being injected into artifact content.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from avos_cli.models.diff import DiffResult, DiffStatus
|
|
15
|
+
from avos_cli.utils.logger import get_logger
|
|
16
|
+
|
|
17
|
+
_log = get_logger("diff_summary_service")
|
|
18
|
+
|
|
19
|
+
_AGENTS_DIR = Path(__file__).resolve().parent.parent / "agents"
|
|
20
|
+
_GIT_DIFF_AGENT_PATH = _AGENTS_DIR / "git_diff_agent.md"
|
|
21
|
+
|
|
22
|
+
_MAX_TOKENS = 1500
|
|
23
|
+
_TEMPERATURE = 0.1
|
|
24
|
+
_REQUEST_TIMEOUT = 60.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DiffSummaryService:
|
|
28
|
+
"""Summarizes git diffs using REPLY_MODEL via git_diff_agent.md prompt.
|
|
29
|
+
|
|
30
|
+
Takes resolved DiffResult objects, sends each diff to the LLM for
|
|
31
|
+
summarization, and returns a mapping of canonical_id to summary string.
|
|
32
|
+
Summaries are in-memory only -- no files written to disk.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
api_key: API key for the reply model.
|
|
36
|
+
api_url: Endpoint URL (OpenAI-compatible chat completions).
|
|
37
|
+
model: Model identifier.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, api_key: str, api_url: str, model: str) -> None:
|
|
41
|
+
self._api_key = api_key
|
|
42
|
+
self._api_url = api_url.rstrip("/")
|
|
43
|
+
self._model = model
|
|
44
|
+
self._client = httpx.Client(
|
|
45
|
+
headers={
|
|
46
|
+
"Authorization": f"Bearer {api_key}",
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
},
|
|
49
|
+
timeout=_REQUEST_TIMEOUT,
|
|
50
|
+
)
|
|
51
|
+
self._prompt_template: str | None = None
|
|
52
|
+
|
|
53
|
+
def summarize_diffs(self, results: list[DiffResult]) -> dict[str, str]:
|
|
54
|
+
"""Summarize resolved diffs using the git_diff_agent prompt.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
results: List of DiffResult objects from DiffResolver.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Dict mapping canonical_id to summary string for each successfully
|
|
61
|
+
summarized diff. Unresolved, suppressed, or failed diffs are skipped.
|
|
62
|
+
"""
|
|
63
|
+
summaries: dict[str, str] = {}
|
|
64
|
+
|
|
65
|
+
for result in results:
|
|
66
|
+
if result.status != DiffStatus.RESOLVED:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if result.diff_text is None or not result.diff_text.strip():
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
summary = self._summarize_single(result.canonical_id, result.diff_text)
|
|
73
|
+
if summary:
|
|
74
|
+
summaries[result.canonical_id] = summary
|
|
75
|
+
|
|
76
|
+
return summaries
|
|
77
|
+
|
|
78
|
+
def _summarize_single(self, canonical_id: str, diff_text: str) -> str | None:
|
|
79
|
+
"""Summarize a single diff via LLM call.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
canonical_id: The canonical ID for logging.
|
|
83
|
+
diff_text: The raw git diff text.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Summary string, or None on failure.
|
|
87
|
+
"""
|
|
88
|
+
prompt = self._load_prompt(diff_text)
|
|
89
|
+
if not prompt:
|
|
90
|
+
_log.warning("Failed to load git_diff_agent prompt for %s", canonical_id)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
body = {
|
|
95
|
+
"model": self._model,
|
|
96
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
97
|
+
"max_tokens": _MAX_TOKENS,
|
|
98
|
+
"temperature": _TEMPERATURE,
|
|
99
|
+
}
|
|
100
|
+
response = self._client.post(self._api_url, json=body)
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
data = response.json()
|
|
103
|
+
|
|
104
|
+
choices = data.get("choices", [])
|
|
105
|
+
if not choices:
|
|
106
|
+
_log.warning("Empty choices in response for %s", canonical_id)
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
content = choices[0].get("message", {}).get("content")
|
|
110
|
+
if not content:
|
|
111
|
+
_log.warning("No content in response for %s", canonical_id)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
return str(content).strip()
|
|
115
|
+
|
|
116
|
+
except httpx.HTTPStatusError as e:
|
|
117
|
+
_log.warning("HTTP error summarizing %s: %s", canonical_id, e)
|
|
118
|
+
return None
|
|
119
|
+
except httpx.TimeoutException as e:
|
|
120
|
+
_log.warning("Timeout summarizing %s: %s", canonical_id, e)
|
|
121
|
+
return None
|
|
122
|
+
except Exception as e:
|
|
123
|
+
_log.warning("Error summarizing %s: %s", canonical_id, e)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def _load_prompt(self, diff_text: str) -> str:
|
|
127
|
+
"""Load and format the git_diff_agent prompt template.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
diff_text: The raw git diff to insert into the template.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Formatted prompt string, or empty string on failure.
|
|
134
|
+
"""
|
|
135
|
+
if self._prompt_template is None:
|
|
136
|
+
if not _GIT_DIFF_AGENT_PATH.exists():
|
|
137
|
+
_log.warning("git_diff_agent.md not found at %s", _GIT_DIFF_AGENT_PATH)
|
|
138
|
+
return ""
|
|
139
|
+
self._prompt_template = _GIT_DIFF_AGENT_PATH.read_text(encoding="utf-8")
|
|
140
|
+
|
|
141
|
+
return self._prompt_template.format(git_diff=diff_text)
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Local Git operations wrapper using subprocess.
|
|
2
|
+
|
|
3
|
+
Provides commit log, branch detection, diff stats, remote parsing,
|
|
4
|
+
modified file listing, and worktree detection. All commands use fixed
|
|
5
|
+
templates (no shell interpolation) for safety.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from avos_cli.exceptions import (
|
|
15
|
+
DependencyUnavailableError,
|
|
16
|
+
RepositoryContextError,
|
|
17
|
+
ServiceParseError,
|
|
18
|
+
)
|
|
19
|
+
from avos_cli.utils.logger import get_logger
|
|
20
|
+
|
|
21
|
+
_log = get_logger("git_client")
|
|
22
|
+
_TIMEOUT = 30
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitClient:
|
|
26
|
+
"""Wrapper for local Git CLI operations.
|
|
27
|
+
|
|
28
|
+
All methods accept a repo_path and shell out to the git binary.
|
|
29
|
+
Errors are normalized to typed exceptions.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def _run_git(self, args: list[str], cwd: Path) -> str:
|
|
33
|
+
"""Execute a git command and return stdout.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
args: Git subcommand and arguments (e.g. ['log', '--oneline']).
|
|
37
|
+
cwd: Working directory for the command.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Stripped stdout string.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
DependencyUnavailableError: If git binary is not found.
|
|
44
|
+
RepositoryContextError: If cwd is not a git repo.
|
|
45
|
+
ServiceParseError: If the command fails unexpectedly.
|
|
46
|
+
"""
|
|
47
|
+
cmd = ["git", *args]
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
cmd,
|
|
51
|
+
cwd=str(cwd),
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=_TIMEOUT,
|
|
55
|
+
)
|
|
56
|
+
except FileNotFoundError as e:
|
|
57
|
+
raise DependencyUnavailableError("git") from e
|
|
58
|
+
except subprocess.TimeoutExpired as e:
|
|
59
|
+
raise ServiceParseError(f"Git command timed out: {' '.join(cmd)}") from e
|
|
60
|
+
|
|
61
|
+
if result.returncode != 0:
|
|
62
|
+
stderr = result.stderr.strip()
|
|
63
|
+
if "not a git repository" in stderr.lower():
|
|
64
|
+
raise RepositoryContextError(f"Not a Git repository: {cwd}")
|
|
65
|
+
if result.returncode != 0 and stderr:
|
|
66
|
+
_log.debug("git stderr: %s", stderr)
|
|
67
|
+
if result.returncode != 0 and not result.stdout.strip():
|
|
68
|
+
return ""
|
|
69
|
+
return result.stdout.rstrip("\n\r")
|
|
70
|
+
|
|
71
|
+
def current_branch(self, repo_path: Path) -> str:
|
|
72
|
+
"""Get the current branch name.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
repo_path: Path to the git repository.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Branch name string (e.g. 'main', 'feature/foo').
|
|
79
|
+
"""
|
|
80
|
+
output = self._run_git(["rev-parse", "--abbrev-ref", "HEAD"], repo_path)
|
|
81
|
+
if not output:
|
|
82
|
+
raise ServiceParseError("Could not determine current branch")
|
|
83
|
+
return output
|
|
84
|
+
|
|
85
|
+
def user_name(self, repo_path: Path) -> str:
|
|
86
|
+
"""Get the configured git user.name."""
|
|
87
|
+
return self._run_git(["config", "user.name"], repo_path)
|
|
88
|
+
|
|
89
|
+
def user_email(self, repo_path: Path) -> str:
|
|
90
|
+
"""Get the configured git user.email."""
|
|
91
|
+
return self._run_git(["config", "user.email"], repo_path)
|
|
92
|
+
|
|
93
|
+
def commit_log(
|
|
94
|
+
self, repo_path: Path, since_date: str | None = None
|
|
95
|
+
) -> list[dict[str, str]]:
|
|
96
|
+
"""Get commit log as a list of dicts.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
repo_path: Path to the git repository.
|
|
100
|
+
since_date: Optional ISO date string for lower bound filter.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of dicts with keys: hash, message, author, date.
|
|
104
|
+
"""
|
|
105
|
+
args = [
|
|
106
|
+
"log",
|
|
107
|
+
"--format=%H|%s|%an|%aI",
|
|
108
|
+
]
|
|
109
|
+
if since_date:
|
|
110
|
+
args.append(f"--since={since_date}")
|
|
111
|
+
|
|
112
|
+
output = self._run_git(args, repo_path)
|
|
113
|
+
if not output:
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
commits: list[dict[str, str]] = []
|
|
117
|
+
for line in output.splitlines():
|
|
118
|
+
parts = line.split("|", 3)
|
|
119
|
+
if len(parts) >= 4:
|
|
120
|
+
commits.append({
|
|
121
|
+
"hash": parts[0],
|
|
122
|
+
"message": parts[1],
|
|
123
|
+
"author": parts[2],
|
|
124
|
+
"date": parts[3],
|
|
125
|
+
})
|
|
126
|
+
return commits
|
|
127
|
+
|
|
128
|
+
def commit_log_range(
|
|
129
|
+
self, repo_path: Path, old_sha: str, new_sha: str
|
|
130
|
+
) -> list[dict[str, str]]:
|
|
131
|
+
"""Get commits between two SHAs (exclusive old, inclusive new).
|
|
132
|
+
|
|
133
|
+
Used by hook-sync to get commits being pushed. The range old_sha..new_sha
|
|
134
|
+
returns commits reachable from new_sha but not from old_sha.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
repo_path: Path to the git repository.
|
|
138
|
+
old_sha: Base commit SHA (exclusive). Use empty string for new branches.
|
|
139
|
+
new_sha: Target commit SHA (inclusive).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List of dicts with keys: hash, message, author, date.
|
|
143
|
+
Returns empty list if range is invalid or empty.
|
|
144
|
+
"""
|
|
145
|
+
if not new_sha:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
range_spec = f"{old_sha}..{new_sha}" if old_sha else new_sha
|
|
149
|
+
|
|
150
|
+
args = ["log", "--format=%H|%s|%an|%aI", range_spec]
|
|
151
|
+
output = self._run_git(args, repo_path)
|
|
152
|
+
if not output:
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
commits: list[dict[str, str]] = []
|
|
156
|
+
for line in output.splitlines():
|
|
157
|
+
parts = line.split("|", 3)
|
|
158
|
+
if len(parts) >= 4:
|
|
159
|
+
commits.append({
|
|
160
|
+
"hash": parts[0],
|
|
161
|
+
"message": parts[1],
|
|
162
|
+
"author": parts[2],
|
|
163
|
+
"date": parts[3],
|
|
164
|
+
})
|
|
165
|
+
return commits
|
|
166
|
+
|
|
167
|
+
def diff_stats(self, repo_path: Path) -> str:
|
|
168
|
+
"""Get diff stats for staged changes.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
repo_path: Path to the git repository.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Diff stat summary string, or empty string if clean.
|
|
175
|
+
"""
|
|
176
|
+
return self._run_git(["diff", "--cached", "--stat"], repo_path)
|
|
177
|
+
|
|
178
|
+
def modified_files(self, repo_path: Path) -> list[str]:
|
|
179
|
+
"""List files modified or untracked in the working directory.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
repo_path: Path to the git repository.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of relative file paths.
|
|
186
|
+
"""
|
|
187
|
+
output = self._run_git(
|
|
188
|
+
["status", "--porcelain", "--untracked-files=all"],
|
|
189
|
+
repo_path,
|
|
190
|
+
)
|
|
191
|
+
if not output:
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
files: list[str] = []
|
|
195
|
+
for line in output.splitlines():
|
|
196
|
+
# porcelain format: "XY filename" where XY is 2 status chars + 1 space
|
|
197
|
+
if len(line) >= 4:
|
|
198
|
+
path = line[3:]
|
|
199
|
+
if " -> " in path:
|
|
200
|
+
path = path.split(" -> ", 1)[1]
|
|
201
|
+
files.append(path)
|
|
202
|
+
return files
|
|
203
|
+
|
|
204
|
+
def remote_origin(self, repo_path: Path) -> str | None:
|
|
205
|
+
"""Extract org/repo from the origin remote URL.
|
|
206
|
+
|
|
207
|
+
Handles both HTTPS and SSH remote formats.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
repo_path: Path to the git repository.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
'org/repo' string, or None if no origin remote.
|
|
214
|
+
"""
|
|
215
|
+
output = self._run_git(["remote", "get-url", "origin"], repo_path)
|
|
216
|
+
if not output:
|
|
217
|
+
return None
|
|
218
|
+
return _parse_remote_url(output)
|
|
219
|
+
|
|
220
|
+
def worktree_add(self, repo_path: Path, path: Path, branch: str) -> Path:
|
|
221
|
+
"""Create a new git worktree with a new branch.
|
|
222
|
+
|
|
223
|
+
Runs `git worktree add -b <branch> <path>`. The branch is created
|
|
224
|
+
from the current HEAD of repo_path.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
repo_path: Path to the source git repository.
|
|
228
|
+
path: Filesystem path for the new worktree.
|
|
229
|
+
branch: Name of the new branch to create.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Resolved Path to the created worktree directory.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
ServiceParseError: If git worktree add fails (path exists,
|
|
236
|
+
branch already checked out, etc.).
|
|
237
|
+
RepositoryContextError: If repo_path is not a git repo.
|
|
238
|
+
"""
|
|
239
|
+
resolved = path.resolve()
|
|
240
|
+
result = subprocess.run(
|
|
241
|
+
["git", "worktree", "add", "-b", branch, str(resolved)],
|
|
242
|
+
cwd=str(repo_path),
|
|
243
|
+
capture_output=True,
|
|
244
|
+
text=True,
|
|
245
|
+
timeout=_TIMEOUT,
|
|
246
|
+
)
|
|
247
|
+
if result.returncode != 0:
|
|
248
|
+
stderr = result.stderr.strip()
|
|
249
|
+
if "not a git repository" in stderr.lower():
|
|
250
|
+
raise RepositoryContextError(f"Not a Git repository: {repo_path}")
|
|
251
|
+
raise ServiceParseError(
|
|
252
|
+
f"git worktree add failed: {stderr or 'unknown error'}"
|
|
253
|
+
)
|
|
254
|
+
return resolved
|
|
255
|
+
|
|
256
|
+
def worktree_list(self, repo_path: Path) -> list[Path]:
|
|
257
|
+
"""List all worktree paths for the repository.
|
|
258
|
+
|
|
259
|
+
Parses `git worktree list --porcelain` output where each worktree
|
|
260
|
+
block starts with a `worktree <path>` line.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
repo_path: Path to any worktree or the main repo.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
List of resolved Paths for all worktrees (including main).
|
|
267
|
+
"""
|
|
268
|
+
output = self._run_git(["worktree", "list", "--porcelain"], repo_path)
|
|
269
|
+
paths: list[Path] = []
|
|
270
|
+
for line in output.splitlines():
|
|
271
|
+
if line.startswith("worktree "):
|
|
272
|
+
wt_path = line[len("worktree "):]
|
|
273
|
+
paths.append(Path(wt_path).resolve())
|
|
274
|
+
return paths
|
|
275
|
+
|
|
276
|
+
def is_worktree(self, repo_path: Path) -> bool:
|
|
277
|
+
"""Check if the repo path is a git worktree (not the main repo).
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
repo_path: Path to check.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
True if the path is a worktree.
|
|
284
|
+
"""
|
|
285
|
+
git_path = repo_path / ".git"
|
|
286
|
+
return git_path.is_file()
|
|
287
|
+
|
|
288
|
+
def commit_patch(self, repo_path: Path, sha: str) -> str:
|
|
289
|
+
"""Get the unified diff patch for a single commit.
|
|
290
|
+
|
|
291
|
+
Uses `git show --format= <sha>` to get the patch. For merge commits,
|
|
292
|
+
uses first-parent diff (--first-parent) to avoid combined diff noise.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
repo_path: Path to the git repository.
|
|
296
|
+
sha: Commit SHA (short or full).
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Unified diff text, or empty string if commit not found.
|
|
300
|
+
"""
|
|
301
|
+
args = ["show", "--format=", "--first-parent", "-p", sha]
|
|
302
|
+
return self._run_git(args, repo_path)
|
|
303
|
+
|
|
304
|
+
def expand_short_sha(self, repo_path: Path, short_sha: str) -> str | None:
|
|
305
|
+
"""Expand a short SHA to the full 40-character SHA.
|
|
306
|
+
|
|
307
|
+
Uses `git rev-parse <sha>` to resolve the full SHA.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
repo_path: Path to the git repository.
|
|
311
|
+
short_sha: Short or full commit SHA.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Full 40-character SHA, or None if not found or ambiguous.
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
RepositoryContextError: If repo_path is not a git repo.
|
|
318
|
+
"""
|
|
319
|
+
output = self._run_git(["rev-parse", short_sha], repo_path)
|
|
320
|
+
if not output or len(output) != 40:
|
|
321
|
+
return None
|
|
322
|
+
if not all(c in "0123456789abcdef" for c in output.lower()):
|
|
323
|
+
return None
|
|
324
|
+
return output
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _parse_remote_url(url: str) -> str | None:
|
|
328
|
+
"""Extract org/repo from a git remote URL.
|
|
329
|
+
|
|
330
|
+
Supports:
|
|
331
|
+
- https://github.com/org/repo.git
|
|
332
|
+
- git@github.com:org/repo.git
|
|
333
|
+
- https://github.com/org/repo
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
url: Remote URL string.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
'org/repo' string or None if unparseable.
|
|
340
|
+
"""
|
|
341
|
+
# SSH format: git@github.com:org/repo.git
|
|
342
|
+
ssh_match = re.match(r"git@[^:]+:(.+?)(?:\.git)?$", url)
|
|
343
|
+
if ssh_match:
|
|
344
|
+
return ssh_match.group(1)
|
|
345
|
+
|
|
346
|
+
# HTTPS format: https://github.com/org/repo.git
|
|
347
|
+
https_match = re.match(r"https?://[^/]+/(.+?)(?:\.git)?$", url)
|
|
348
|
+
if https_match:
|
|
349
|
+
return https_match.group(1)
|
|
350
|
+
|
|
351
|
+
return None
|