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.
@@ -0,0 +1,11 @@
1
+ """Git diff extraction and data models."""
2
+
3
+ from .git import GitRepository
4
+ from .models import DiffResult, FileDiff, Hunk
5
+
6
+ __all__ = [
7
+ "DiffResult",
8
+ "FileDiff",
9
+ "GitRepository",
10
+ "Hunk",
11
+ ]
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."""
@@ -0,0 +1,5 @@
1
+ """Vector index construction and retrieval."""
2
+
3
+ from .indexer import RepoIndexer
4
+
5
+ __all__ = ["RepoIndexer"]