diffrag 0.2.3__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.
- diffrag/__init__.py +101 -0
- diffrag/__main__.py +3 -0
- diffrag/ai/__init__.py +15 -0
- diffrag/ai/base.py +106 -0
- diffrag/ai/ollama.py +144 -0
- diffrag/ai/openai_compat.py +193 -0
- diffrag/cli/__init__.py +5 -0
- diffrag/cli/main.py +440 -0
- diffrag/config/__init__.py +21 -0
- diffrag/config/settings.py +190 -0
- diffrag/data/config.example.toml +53 -0
- diffrag/diff/__init__.py +11 -0
- diffrag/diff/git.py +227 -0
- diffrag/diff/models.py +76 -0
- diffrag/exceptions.py +21 -0
- diffrag/indexing/__init__.py +5 -0
- diffrag/indexing/indexer.py +387 -0
- diffrag/indexing/splitter.py +296 -0
- diffrag/py.typed +0 -0
- diffrag/review/__init__.py +5 -0
- diffrag/review/prompts.py +75 -0
- diffrag/review/reviewer.py +322 -0
- diffrag-0.2.3.dist-info/METADATA +425 -0
- diffrag-0.2.3.dist-info/RECORD +26 -0
- diffrag-0.2.3.dist-info/WHEEL +4 -0
- diffrag-0.2.3.dist-info/entry_points.txt +3 -0
diffrag/diff/__init__.py
ADDED
diffrag/diff/git.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""GitRepository: thin wrapper around git subprocess calls."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..exceptions import GitError
|
|
9
|
+
from .models import DiffResult, FileDiff, Hunk
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_HUNK_HEADER_RE = re.compile(
|
|
14
|
+
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@",
|
|
15
|
+
re.MULTILINE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_STATUS_MAP = {
|
|
19
|
+
"M": "modified",
|
|
20
|
+
"A": "added",
|
|
21
|
+
"D": "deleted",
|
|
22
|
+
"R": "renamed",
|
|
23
|
+
"C": "copied",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitRepository:
|
|
28
|
+
"""Provides access to git operations for a local repository.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
path: Absolute or relative path to the root of the git repository.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
GitError: If ``path`` is not inside a git repository.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, path: Path) -> None:
|
|
38
|
+
self._path = path.resolve()
|
|
39
|
+
self._validate()
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def path(self) -> Path:
|
|
43
|
+
"""Absolute path to the repository root.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Resolved ``Path`` object pointing to the repository root.
|
|
47
|
+
"""
|
|
48
|
+
return self._path
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Public API
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def head_commit(self) -> str:
|
|
55
|
+
"""Return the SHA of the current HEAD commit.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Full 40-character SHA string.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
GitError: If the git command fails (e.g. no commits yet).
|
|
62
|
+
"""
|
|
63
|
+
return self._run("rev-parse", "HEAD").strip()
|
|
64
|
+
|
|
65
|
+
def list_branches(self) -> list[str]:
|
|
66
|
+
"""Return all local branch names.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Sorted list of local branch names.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
GitError: If the git command fails.
|
|
73
|
+
"""
|
|
74
|
+
output = self._run("branch", "--format=%(refname:short)")
|
|
75
|
+
return sorted(line.strip() for line in output.splitlines() if line.strip())
|
|
76
|
+
|
|
77
|
+
def get_diff(self, base: str, head: str = "HEAD") -> DiffResult:
|
|
78
|
+
"""Extract the diff between *base* and *head*.
|
|
79
|
+
|
|
80
|
+
Uses the three-dot ``base...head`` notation so only commits reachable
|
|
81
|
+
from *head* but not from *base* are included (merge-base diff).
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
base: Base ref (branch, tag, or commit SHA).
|
|
85
|
+
head: Head ref to compare against. Defaults to ``"HEAD"``.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A :class:`DiffResult` containing per-file diffs.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
GitError: If any git command fails or a ref does not exist.
|
|
92
|
+
"""
|
|
93
|
+
log.info("Computing diff %s...%s in %s", base, head, self._path)
|
|
94
|
+
|
|
95
|
+
name_status = self._run("diff", "--name-status", f"{base}...{head}")
|
|
96
|
+
if not name_status.strip():
|
|
97
|
+
log.info("No changes found between %s and %s", base, head)
|
|
98
|
+
return DiffResult(base_ref=base, head_ref=head, file_diffs=())
|
|
99
|
+
|
|
100
|
+
file_entries = self._parse_name_status(name_status)
|
|
101
|
+
file_diffs: list[FileDiff] = []
|
|
102
|
+
|
|
103
|
+
for status, new_path, old_path in file_entries:
|
|
104
|
+
raw = self._run("--no-pager", "diff", "--unified=3", f"{base}...{head}", "--", new_path)
|
|
105
|
+
hunks = self._parse_hunks(raw)
|
|
106
|
+
file_diffs.append(
|
|
107
|
+
FileDiff(
|
|
108
|
+
path=new_path,
|
|
109
|
+
old_path=old_path,
|
|
110
|
+
status=_STATUS_MAP.get(status, status.lower()),
|
|
111
|
+
hunks=tuple(hunks),
|
|
112
|
+
raw_diff=raw,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
log.debug("Parsed %d hunks for %s (%s)", len(hunks), new_path, status)
|
|
116
|
+
|
|
117
|
+
return DiffResult(base_ref=base, head_ref=head, file_diffs=tuple(file_diffs))
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Internal helpers
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _validate(self) -> None:
|
|
124
|
+
try:
|
|
125
|
+
self._run("rev-parse", "--git-dir")
|
|
126
|
+
except GitError as exc:
|
|
127
|
+
raise GitError(f"{self._path} is not a git repository") from exc
|
|
128
|
+
|
|
129
|
+
def _run(self, *args: str) -> str:
|
|
130
|
+
"""Run a git command and return stdout.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
*args: Arguments passed to ``git`` after the executable name.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Decoded stdout string.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
GitError: If git exits with a non-zero status.
|
|
140
|
+
"""
|
|
141
|
+
cmd = ["git", *args]
|
|
142
|
+
log.debug("Running: %s", " ".join(cmd))
|
|
143
|
+
try:
|
|
144
|
+
result = subprocess.run(
|
|
145
|
+
cmd,
|
|
146
|
+
cwd=self._path,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
check=True,
|
|
150
|
+
)
|
|
151
|
+
return result.stdout
|
|
152
|
+
except subprocess.CalledProcessError as exc:
|
|
153
|
+
raise GitError(f"git command failed: {' '.join(cmd)}\n{exc.stderr.strip()}") from exc
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _parse_name_status(output: str) -> list[tuple[str, str, str | None]]:
|
|
157
|
+
"""Parse ``git diff --name-status`` output.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
output: Raw output from ``git diff --name-status``.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of ``(status_char, new_path, old_path_or_None)`` tuples.
|
|
164
|
+
"""
|
|
165
|
+
entries: list[tuple[str, str, str | None]] = []
|
|
166
|
+
for line in output.strip().splitlines():
|
|
167
|
+
parts = line.split("\t")
|
|
168
|
+
status_code = parts[0][0]
|
|
169
|
+
if status_code in ("R", "C") and len(parts) == 3:
|
|
170
|
+
entries.append((status_code, parts[2], parts[1]))
|
|
171
|
+
elif len(parts) >= 2:
|
|
172
|
+
entries.append((status_code, parts[1], None))
|
|
173
|
+
return entries
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _parse_hunks(diff_text: str) -> list[Hunk]:
|
|
177
|
+
"""Parse hunk blocks out of a unified diff string.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
diff_text: Raw unified diff for a single file.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of :class:`Hunk` objects in order of appearance.
|
|
184
|
+
"""
|
|
185
|
+
hunks: list[Hunk] = []
|
|
186
|
+
lines = diff_text.splitlines(keepends=True)
|
|
187
|
+
|
|
188
|
+
current_header: str | None = None
|
|
189
|
+
current_lines: list[str] = []
|
|
190
|
+
old_start = old_count = new_start = new_count = 0
|
|
191
|
+
|
|
192
|
+
for line in lines:
|
|
193
|
+
m = _HUNK_HEADER_RE.match(line)
|
|
194
|
+
if m:
|
|
195
|
+
if current_header is not None:
|
|
196
|
+
hunks.append(
|
|
197
|
+
Hunk(
|
|
198
|
+
header=current_header,
|
|
199
|
+
content="".join(current_lines),
|
|
200
|
+
old_start=old_start,
|
|
201
|
+
old_count=old_count,
|
|
202
|
+
new_start=new_start,
|
|
203
|
+
new_count=new_count,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
current_header = line.rstrip("\n")
|
|
207
|
+
current_lines = []
|
|
208
|
+
old_start = int(m.group(1))
|
|
209
|
+
old_count = int(m.group(2)) if m.group(2) is not None else 1
|
|
210
|
+
new_start = int(m.group(3))
|
|
211
|
+
new_count = int(m.group(4)) if m.group(4) is not None else 1
|
|
212
|
+
elif current_header is not None:
|
|
213
|
+
current_lines.append(line)
|
|
214
|
+
|
|
215
|
+
if current_header is not None:
|
|
216
|
+
hunks.append(
|
|
217
|
+
Hunk(
|
|
218
|
+
header=current_header,
|
|
219
|
+
content="".join(current_lines),
|
|
220
|
+
old_start=old_start,
|
|
221
|
+
old_count=old_count,
|
|
222
|
+
new_start=new_start,
|
|
223
|
+
new_count=new_count,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return hunks
|
diffrag/diff/models.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Immutable data models representing a parsed git diff."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Hunk:
|
|
8
|
+
"""A single contiguous change block within a file diff.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
header: The ``@@ ... @@`` line that introduces the hunk.
|
|
12
|
+
content: All diff lines belonging to this hunk (context, additions, deletions).
|
|
13
|
+
old_start: First line number in the old file.
|
|
14
|
+
old_count: Number of lines from the old file covered by this hunk.
|
|
15
|
+
new_start: First line number in the new file.
|
|
16
|
+
new_count: Number of lines in the new file covered by this hunk.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
header: str
|
|
20
|
+
content: str
|
|
21
|
+
old_start: int
|
|
22
|
+
old_count: int
|
|
23
|
+
new_start: int
|
|
24
|
+
new_count: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class FileDiff:
|
|
29
|
+
"""Diff for a single file in a changeset.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
path: Current (new) path of the file, relative to the repository root.
|
|
33
|
+
old_path: Previous path before a rename/copy; ``None`` otherwise.
|
|
34
|
+
status: One of ``"modified"``, ``"added"``, ``"deleted"``, ``"renamed"``, ``"copied"``.
|
|
35
|
+
hunks: Ordered list of parsed hunks.
|
|
36
|
+
raw_diff: Full raw diff text for this file as returned by git.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
path: str
|
|
40
|
+
old_path: str | None
|
|
41
|
+
status: str
|
|
42
|
+
hunks: tuple[Hunk, ...]
|
|
43
|
+
raw_diff: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class DiffResult:
|
|
48
|
+
"""The complete diff between two git refs.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
base_ref: The base git ref (branch, tag, or commit).
|
|
52
|
+
head_ref: The head git ref being compared.
|
|
53
|
+
file_diffs: Tuple of per-file diffs in the changeset.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
base_ref: str
|
|
57
|
+
head_ref: str
|
|
58
|
+
file_diffs: tuple[FileDiff, ...]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def changed_files(self) -> list[str]:
|
|
62
|
+
"""Return a list of changed file paths.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of file paths (current path for renames).
|
|
66
|
+
"""
|
|
67
|
+
return [f.path for f in self.file_diffs]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_empty(self) -> bool:
|
|
71
|
+
"""Return ``True`` if there are no changed files.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
``True`` when the diff contains no file changes.
|
|
75
|
+
"""
|
|
76
|
+
return len(self.file_diffs) == 0
|
diffrag/exceptions.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Package-level exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AICodeReviewerError(Exception):
|
|
5
|
+
"""Base exception for diffrag."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitError(AICodeReviewerError):
|
|
9
|
+
"""Raised when a git operation fails."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AIClientError(AICodeReviewerError):
|
|
13
|
+
"""Raised when an AI API request fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IndexingError(AICodeReviewerError):
|
|
17
|
+
"""Raised when building or querying the vector index fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigurationError(AICodeReviewerError):
|
|
21
|
+
"""Raised for invalid or missing configuration."""
|