difflicious 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.
- difflicious/__init__.py +6 -0
- difflicious/app.py +505 -0
- difflicious/cli.py +77 -0
- difflicious/diff_parser.py +525 -0
- difflicious/dummy_data.json +44 -0
- difflicious/git_operations.py +1005 -0
- difflicious/services/__init__.py +1 -0
- difflicious/services/base_service.py +32 -0
- difflicious/services/diff_service.py +403 -0
- difflicious/services/exceptions.py +19 -0
- difflicious/services/git_service.py +135 -0
- difflicious/services/syntax_service.py +162 -0
- difflicious/services/template_service.py +382 -0
- difflicious/static/css/styles.css +885 -0
- difflicious/static/css/tailwind.css +1 -0
- difflicious/static/css/tailwind.input.css +5 -0
- difflicious/static/js/app.js +1002 -0
- difflicious/static/js/diff-interactions.js +1617 -0
- difflicious/templates/base.html +54 -0
- difflicious/templates/diff_file.html +90 -0
- difflicious/templates/diff_groups.html +29 -0
- difflicious/templates/diff_hunk.html +170 -0
- difflicious/templates/index.html +54 -0
- difflicious/templates/partials/empty_state.html +29 -0
- difflicious/templates/partials/global_controls.html +23 -0
- difflicious/templates/partials/loading_state.html +7 -0
- difflicious/templates/partials/toolbar.html +165 -0
- difflicious-0.1.0.dist-info/METADATA +190 -0
- difflicious-0.1.0.dist-info/RECORD +32 -0
- difflicious-0.1.0.dist-info/WHEEL +4 -0
- difflicious-0.1.0.dist-info/entry_points.txt +2 -0
- difflicious-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Service layer for business logic separation."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Base service class for common functionality."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from difflicious.git_operations import GitRepository, get_git_repository
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseService:
|
|
12
|
+
"""Base class for all services with common functionality."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, repo_path: Optional[str] = None):
|
|
15
|
+
"""Initialize service with git repository.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
repo_path: Optional path to git repository
|
|
19
|
+
"""
|
|
20
|
+
self._repo: Optional[GitRepository] = None
|
|
21
|
+
self._repo_path = repo_path
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def repo(self) -> GitRepository:
|
|
25
|
+
"""Lazy-loaded git repository instance."""
|
|
26
|
+
if self._repo is None:
|
|
27
|
+
self._repo = get_git_repository(self._repo_path)
|
|
28
|
+
return self._repo
|
|
29
|
+
|
|
30
|
+
def _log_error(self, message: str, exception: Exception) -> None:
|
|
31
|
+
"""Consistent error logging across services."""
|
|
32
|
+
logger.error(f"{self.__class__.__name__}: {message} - {exception}")
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Service for handling diff-related business logic."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from difflicious.diff_parser import DiffParseError, parse_git_diff_for_rendering
|
|
7
|
+
from difflicious.git_operations import GitOperationError
|
|
8
|
+
|
|
9
|
+
from .base_service import BaseService
|
|
10
|
+
from .exceptions import DiffServiceError
|
|
11
|
+
from .syntax_service import SyntaxHighlightingService
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DiffService(BaseService):
|
|
17
|
+
"""Service for diff-related operations and business logic."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, repo_path: Optional[str] = None) -> None:
|
|
20
|
+
"""Initialize the diff service."""
|
|
21
|
+
super().__init__(repo_path)
|
|
22
|
+
self.syntax_service = SyntaxHighlightingService()
|
|
23
|
+
|
|
24
|
+
def get_grouped_diffs(
|
|
25
|
+
self,
|
|
26
|
+
base_ref: Optional[str] = None,
|
|
27
|
+
unstaged: bool = True,
|
|
28
|
+
untracked: bool = False,
|
|
29
|
+
file_path: Optional[str] = None,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""Get processed diff data grouped by type.
|
|
32
|
+
|
|
33
|
+
This method extracts the business logic currently in get_real_git_diff()
|
|
34
|
+
from app.py and makes it independently testable.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
base_ref: Base reference for comparison (HEAD or branch)
|
|
38
|
+
unstaged: Whether to include unstaged changes (mapped to include_unstaged)
|
|
39
|
+
untracked: Whether to include untracked files (mapped to include_untracked)
|
|
40
|
+
file_path: Optional specific file to diff
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Dictionary with grouped diff data
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
DiffServiceError: If diff processing fails
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
# Determine comparison mode from base_ref
|
|
50
|
+
# HEAD or current branch implies HEAD comparison; otherwise branch comparison
|
|
51
|
+
current_branch = self.repo.get_current_branch()
|
|
52
|
+
use_head = base_ref in ["HEAD", current_branch] if base_ref else False
|
|
53
|
+
|
|
54
|
+
# Get raw diff data from git operations using new interface
|
|
55
|
+
grouped_diffs = self.repo.get_diff(
|
|
56
|
+
use_head=use_head,
|
|
57
|
+
include_unstaged=unstaged,
|
|
58
|
+
include_untracked=untracked,
|
|
59
|
+
file_path=file_path,
|
|
60
|
+
base_ref=base_ref,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Process each group to parse diff content for rendering
|
|
64
|
+
return self._process_diff_groups(grouped_diffs)
|
|
65
|
+
|
|
66
|
+
except GitOperationError as e:
|
|
67
|
+
self._log_error("Git operation failed during diff retrieval", e)
|
|
68
|
+
raise DiffServiceError(f"Failed to retrieve diff data: {e}") from e
|
|
69
|
+
except Exception as e:
|
|
70
|
+
self._log_error("Unexpected error during diff processing", e)
|
|
71
|
+
raise DiffServiceError(f"Diff processing failed: {e}") from e
|
|
72
|
+
|
|
73
|
+
def _process_diff_groups(self, grouped_diffs: dict[str, Any]) -> dict[str, Any]:
|
|
74
|
+
"""Process raw diff groups into rendered format.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
grouped_diffs: Raw diff data from git operations
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Processed diff data ready for frontend consumption
|
|
81
|
+
"""
|
|
82
|
+
# Track old paths from renames to filter them out
|
|
83
|
+
old_paths_from_renames = set()
|
|
84
|
+
|
|
85
|
+
# First pass: identify old paths from renames
|
|
86
|
+
for _group_name, group_data in grouped_diffs.items():
|
|
87
|
+
for diff in group_data["files"]:
|
|
88
|
+
if diff.get("status") == "renamed" and diff.get("old_path"):
|
|
89
|
+
old_paths_from_renames.add(diff["old_path"])
|
|
90
|
+
|
|
91
|
+
# Second pass: process files and filter out old paths
|
|
92
|
+
for _group_name, group_data in grouped_diffs.items():
|
|
93
|
+
formatted_files = []
|
|
94
|
+
|
|
95
|
+
for diff in group_data["files"]:
|
|
96
|
+
# Skip old paths from renames
|
|
97
|
+
if diff.get("path") in old_paths_from_renames:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# Skip files with rename notation in path (e.g., "{doc => docs}/..." or "old => new")
|
|
101
|
+
path = diff.get("path", "")
|
|
102
|
+
# Only filter if the path contains "=>" and looks like a file path (not just line numbers)
|
|
103
|
+
if ("=>" in path and ("/" in path or path.startswith("{"))) or (
|
|
104
|
+
path.startswith("{") and "}" in path
|
|
105
|
+
):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
processed_diff = self._process_single_diff(diff)
|
|
109
|
+
formatted_files.append(processed_diff)
|
|
110
|
+
|
|
111
|
+
group_data["files"] = formatted_files
|
|
112
|
+
group_data["count"] = len(formatted_files)
|
|
113
|
+
|
|
114
|
+
return grouped_diffs
|
|
115
|
+
|
|
116
|
+
def _process_single_diff(self, diff: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
"""Process a single diff file.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
diff: Raw diff data for a single file
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Processed diff data
|
|
124
|
+
"""
|
|
125
|
+
# Parse the diff content if available (but not for untracked files)
|
|
126
|
+
if diff.get("content") and diff.get("status") != "untracked":
|
|
127
|
+
try:
|
|
128
|
+
parsed_diff = parse_git_diff_for_rendering(diff["content"])
|
|
129
|
+
if parsed_diff:
|
|
130
|
+
# Take the first parsed diff item and update it with our metadata
|
|
131
|
+
formatted_diff = parsed_diff[0]
|
|
132
|
+
update_data = {
|
|
133
|
+
"path": diff["path"],
|
|
134
|
+
"additions": diff["additions"],
|
|
135
|
+
"deletions": diff["deletions"],
|
|
136
|
+
"changes": diff["changes"],
|
|
137
|
+
"status": diff["status"],
|
|
138
|
+
}
|
|
139
|
+
# Preserve old_path for renamed files
|
|
140
|
+
if "old_path" in diff:
|
|
141
|
+
update_data["old_path"] = diff["old_path"]
|
|
142
|
+
formatted_diff.update(update_data)
|
|
143
|
+
return formatted_diff
|
|
144
|
+
except DiffParseError as e:
|
|
145
|
+
logger.warning(f"Failed to parse diff for {diff['path']}: {e}")
|
|
146
|
+
# Fall through to return raw diff
|
|
147
|
+
|
|
148
|
+
# For files without content or parsing failures, return as-is
|
|
149
|
+
return diff
|
|
150
|
+
|
|
151
|
+
def get_diff_summary(self, **kwargs: Any) -> dict[str, Any]:
|
|
152
|
+
"""Get summary statistics for diffs.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
**kwargs: Arguments passed to get_grouped_diffs
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Summary statistics dictionary
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
grouped_diffs = self.get_grouped_diffs(**kwargs)
|
|
162
|
+
|
|
163
|
+
total_files = sum(group["count"] for group in grouped_diffs.values())
|
|
164
|
+
total_additions = 0
|
|
165
|
+
total_deletions = 0
|
|
166
|
+
|
|
167
|
+
for group in grouped_diffs.values():
|
|
168
|
+
for file_data in group["files"]:
|
|
169
|
+
total_additions += file_data.get("additions", 0)
|
|
170
|
+
total_deletions += file_data.get("deletions", 0)
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"total_files": total_files,
|
|
174
|
+
"total_additions": total_additions,
|
|
175
|
+
"total_deletions": total_deletions,
|
|
176
|
+
"total_changes": total_additions + total_deletions,
|
|
177
|
+
"groups": {
|
|
178
|
+
name: group["count"] for name, group in grouped_diffs.items()
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
except DiffServiceError:
|
|
183
|
+
raise
|
|
184
|
+
except Exception as e:
|
|
185
|
+
raise DiffServiceError(f"Failed to generate diff summary: {e}") from e
|
|
186
|
+
|
|
187
|
+
def get_full_diff_data(
|
|
188
|
+
self,
|
|
189
|
+
file_path: str,
|
|
190
|
+
base_ref: Optional[str] = None,
|
|
191
|
+
use_head: bool = False,
|
|
192
|
+
use_cached: bool = False,
|
|
193
|
+
) -> dict[str, Any]:
|
|
194
|
+
"""Get complete diff data for a specific file with unlimited context.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
file_path: Path to the file to diff
|
|
198
|
+
base_ref: Base reference for comparison (defaults to main branch)
|
|
199
|
+
use_head: Whether to compare against HEAD instead of branch
|
|
200
|
+
use_cached: Whether to get staged diff
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Dictionary containing full diff data with parsed content
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
DiffServiceError: If diff processing fails
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
# Get raw full diff content
|
|
210
|
+
full_diff_content = self.repo.get_full_file_diff(
|
|
211
|
+
file_path=file_path,
|
|
212
|
+
base_ref=base_ref,
|
|
213
|
+
use_head=use_head,
|
|
214
|
+
use_cached=use_cached,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if not full_diff_content.strip():
|
|
218
|
+
# No diff content (files are identical)
|
|
219
|
+
return {
|
|
220
|
+
"status": "ok",
|
|
221
|
+
"file_path": file_path,
|
|
222
|
+
"diff_content": "",
|
|
223
|
+
"has_changes": False,
|
|
224
|
+
"comparison_mode": self._get_comparison_mode_description(
|
|
225
|
+
base_ref, use_head, use_cached
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# Parse the diff content for rendering
|
|
230
|
+
try:
|
|
231
|
+
parsed_diff = parse_git_diff_for_rendering(full_diff_content)
|
|
232
|
+
if parsed_diff and len(parsed_diff) > 0:
|
|
233
|
+
# Take the first parsed diff and enhance it
|
|
234
|
+
formatted_diff = parsed_diff[0]
|
|
235
|
+
|
|
236
|
+
# Apply syntax highlighting to the diff content
|
|
237
|
+
formatted_diff = self._apply_syntax_highlighting_to_diff(
|
|
238
|
+
formatted_diff, file_path
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
formatted_diff.update(
|
|
242
|
+
{
|
|
243
|
+
"path": file_path,
|
|
244
|
+
"full_context": True,
|
|
245
|
+
"comparison_mode": self._get_comparison_mode_description(
|
|
246
|
+
base_ref, use_head, use_cached
|
|
247
|
+
),
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
"status": "ok",
|
|
253
|
+
"file_path": file_path,
|
|
254
|
+
"diff_data": formatted_diff,
|
|
255
|
+
"has_changes": True,
|
|
256
|
+
"comparison_mode": self._get_comparison_mode_description(
|
|
257
|
+
base_ref, use_head, use_cached
|
|
258
|
+
),
|
|
259
|
+
}
|
|
260
|
+
else:
|
|
261
|
+
# Parsing failed, return highlighted raw content
|
|
262
|
+
highlighted_diff = self._apply_syntax_highlighting_to_raw_diff(
|
|
263
|
+
full_diff_content, file_path
|
|
264
|
+
)
|
|
265
|
+
return {
|
|
266
|
+
"status": "ok",
|
|
267
|
+
"file_path": file_path,
|
|
268
|
+
"diff_content": highlighted_diff,
|
|
269
|
+
"has_changes": True,
|
|
270
|
+
"comparison_mode": self._get_comparison_mode_description(
|
|
271
|
+
base_ref, use_head, use_cached
|
|
272
|
+
),
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
except DiffParseError as e:
|
|
276
|
+
logger.warning(f"Failed to parse full diff for {file_path}: {e}")
|
|
277
|
+
# Return highlighted raw content if parsing fails
|
|
278
|
+
highlighted_diff = self._apply_syntax_highlighting_to_raw_diff(
|
|
279
|
+
full_diff_content, file_path
|
|
280
|
+
)
|
|
281
|
+
return {
|
|
282
|
+
"status": "ok",
|
|
283
|
+
"file_path": file_path,
|
|
284
|
+
"diff_content": highlighted_diff,
|
|
285
|
+
"has_changes": True,
|
|
286
|
+
"comparison_mode": self._get_comparison_mode_description(
|
|
287
|
+
base_ref, use_head, use_cached
|
|
288
|
+
),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
except GitOperationError as e:
|
|
292
|
+
self._log_error("Git operation failed during full diff retrieval", e)
|
|
293
|
+
raise DiffServiceError(f"Failed to retrieve full diff data: {e}") from e
|
|
294
|
+
except Exception as e:
|
|
295
|
+
self._log_error("Unexpected error during full diff processing", e)
|
|
296
|
+
raise DiffServiceError(f"Full diff processing failed: {e}") from e
|
|
297
|
+
|
|
298
|
+
def _apply_syntax_highlighting_to_diff(
|
|
299
|
+
self, diff_data: dict[str, Any], file_path: str
|
|
300
|
+
) -> dict[str, Any]:
|
|
301
|
+
"""Apply syntax highlighting to diff data.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
diff_data: Parsed diff data structure
|
|
305
|
+
file_path: Path to determine language for highlighting
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Enhanced diff data with syntax highlighting applied
|
|
309
|
+
"""
|
|
310
|
+
if "hunks" not in diff_data:
|
|
311
|
+
return diff_data
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
for hunk in diff_data["hunks"]:
|
|
315
|
+
for line in hunk.get("lines", []):
|
|
316
|
+
# Apply syntax highlighting to left side content
|
|
317
|
+
if line.get("left") and line["left"].get("content"):
|
|
318
|
+
original_content = line["left"]["content"]
|
|
319
|
+
highlighted_content = self.syntax_service.highlight_diff_line(
|
|
320
|
+
original_content, file_path
|
|
321
|
+
)
|
|
322
|
+
line["left"]["content"] = highlighted_content
|
|
323
|
+
|
|
324
|
+
# Apply syntax highlighting to right side content
|
|
325
|
+
if line.get("right") and line["right"].get("content"):
|
|
326
|
+
original_content = line["right"]["content"]
|
|
327
|
+
highlighted_content = self.syntax_service.highlight_diff_line(
|
|
328
|
+
original_content, file_path
|
|
329
|
+
)
|
|
330
|
+
line["right"]["content"] = highlighted_content
|
|
331
|
+
|
|
332
|
+
except Exception as e:
|
|
333
|
+
logger.debug(f"Failed to apply syntax highlighting to diff: {e}")
|
|
334
|
+
# Return original data if highlighting fails
|
|
335
|
+
|
|
336
|
+
return diff_data
|
|
337
|
+
|
|
338
|
+
def _apply_syntax_highlighting_to_raw_diff(
|
|
339
|
+
self, raw_diff: str, file_path: str
|
|
340
|
+
) -> str:
|
|
341
|
+
"""Apply syntax highlighting to raw diff content.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
raw_diff: Raw diff content as string
|
|
345
|
+
file_path: Path to determine language for highlighting
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Syntax highlighted diff content
|
|
349
|
+
"""
|
|
350
|
+
try:
|
|
351
|
+
lines = raw_diff.split("\n")
|
|
352
|
+
highlighted_lines = []
|
|
353
|
+
|
|
354
|
+
for line in lines:
|
|
355
|
+
# Skip diff headers and metadata
|
|
356
|
+
if (
|
|
357
|
+
line.startswith("diff --git")
|
|
358
|
+
or line.startswith("index ")
|
|
359
|
+
or line.startswith("+++")
|
|
360
|
+
or line.startswith("---")
|
|
361
|
+
or line.startswith("@@")
|
|
362
|
+
):
|
|
363
|
+
highlighted_lines.append(line)
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Apply highlighting to content lines (context, additions, deletions)
|
|
367
|
+
if line and len(line) > 1:
|
|
368
|
+
# Get the content without the leading diff marker (+, -, or space)
|
|
369
|
+
content = line[1:] if line[0] in ["+", "-", " "] else line
|
|
370
|
+
if content.strip(): # Only highlight non-empty content
|
|
371
|
+
highlighted_content = self.syntax_service.highlight_diff_line(
|
|
372
|
+
content, file_path
|
|
373
|
+
)
|
|
374
|
+
# Reconstruct the line with the diff marker
|
|
375
|
+
highlighted_line = (
|
|
376
|
+
line[0] + highlighted_content
|
|
377
|
+
if line[0] in ["+", "-", " "]
|
|
378
|
+
else highlighted_content
|
|
379
|
+
)
|
|
380
|
+
highlighted_lines.append(highlighted_line)
|
|
381
|
+
else:
|
|
382
|
+
highlighted_lines.append(line)
|
|
383
|
+
else:
|
|
384
|
+
highlighted_lines.append(line)
|
|
385
|
+
|
|
386
|
+
return "\n".join(highlighted_lines)
|
|
387
|
+
|
|
388
|
+
except Exception as e:
|
|
389
|
+
logger.debug(f"Failed to apply syntax highlighting to raw diff: {e}")
|
|
390
|
+
return raw_diff # Return original content if highlighting fails
|
|
391
|
+
|
|
392
|
+
def _get_comparison_mode_description(
|
|
393
|
+
self, base_ref: Optional[str], use_head: bool, use_cached: bool
|
|
394
|
+
) -> str:
|
|
395
|
+
"""Get a human-readable description of the comparison mode."""
|
|
396
|
+
if use_cached:
|
|
397
|
+
return "staged vs HEAD"
|
|
398
|
+
elif use_head:
|
|
399
|
+
return "working directory vs HEAD"
|
|
400
|
+
elif base_ref:
|
|
401
|
+
return f"working directory vs {base_ref}"
|
|
402
|
+
else:
|
|
403
|
+
return "working directory vs main branch"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Service layer exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ServiceError(Exception):
|
|
5
|
+
"""Base exception for service layer errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DiffServiceError(ServiceError):
|
|
11
|
+
"""Exception raised by diff service operations."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitServiceError(ServiceError):
|
|
17
|
+
"""Exception raised by git service operations."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Service for git-related business logic."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from difflicious.git_operations import GitOperationError
|
|
6
|
+
|
|
7
|
+
from .base_service import BaseService
|
|
8
|
+
from .exceptions import GitServiceError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GitService(BaseService):
|
|
12
|
+
"""Service for git repository operations."""
|
|
13
|
+
|
|
14
|
+
def get_repository_status(self) -> dict[str, Any]:
|
|
15
|
+
"""Get comprehensive repository status.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Repository status dictionary
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
current_branch = self.repo.get_current_branch()
|
|
22
|
+
repo_name = self.repo.get_repository_name()
|
|
23
|
+
|
|
24
|
+
# Use lightweight summary instead of full diff content
|
|
25
|
+
summary = self.repo.summarize_changes(
|
|
26
|
+
include_unstaged=True, include_untracked=True
|
|
27
|
+
)
|
|
28
|
+
total_files = sum(group.get("count", 0) for group in summary.values())
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
"current_branch": current_branch,
|
|
32
|
+
"repository_name": repo_name,
|
|
33
|
+
"files_changed": total_files,
|
|
34
|
+
"git_available": True,
|
|
35
|
+
"status": "ok",
|
|
36
|
+
}
|
|
37
|
+
except GitOperationError as e:
|
|
38
|
+
return {
|
|
39
|
+
"current_branch": "unknown",
|
|
40
|
+
"repository_name": "unknown",
|
|
41
|
+
"files_changed": 0,
|
|
42
|
+
"git_available": False,
|
|
43
|
+
"status": "error",
|
|
44
|
+
"error": str(e),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def get_branch_information(self) -> dict[str, Any]:
|
|
48
|
+
"""Get branch information with error handling.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Branch information dictionary
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
branch_info = self.repo.get_branches()
|
|
55
|
+
current_branch = self.repo.get_current_branch()
|
|
56
|
+
|
|
57
|
+
all_branches = branch_info["branches"]
|
|
58
|
+
default_branch = branch_info["default_branch"]
|
|
59
|
+
|
|
60
|
+
# Build ordered branch list for UI dropdown:
|
|
61
|
+
# 1) Current checked-out branch (fallback to "HEAD" if unknown)
|
|
62
|
+
# 2) Default branch (if available and distinct)
|
|
63
|
+
# 3) All other branches in alphabetical order
|
|
64
|
+
unique_branches = sorted(set(all_branches))
|
|
65
|
+
|
|
66
|
+
has_checked_out_branch = bool(
|
|
67
|
+
current_branch and current_branch not in {"unknown", "error"}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
ordered_all: list[str] = []
|
|
71
|
+
if has_checked_out_branch:
|
|
72
|
+
ordered_all.append(current_branch)
|
|
73
|
+
else:
|
|
74
|
+
ordered_all.append("HEAD")
|
|
75
|
+
|
|
76
|
+
if default_branch and default_branch not in ordered_all:
|
|
77
|
+
ordered_all.append(default_branch)
|
|
78
|
+
|
|
79
|
+
ordered_all.extend([b for b in unique_branches if b not in ordered_all])
|
|
80
|
+
|
|
81
|
+
# Maintain an "others" convenience list (excluding default and current)
|
|
82
|
+
other_branches = [
|
|
83
|
+
b
|
|
84
|
+
for b in unique_branches
|
|
85
|
+
if b != default_branch and b != current_branch
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
"status": "ok",
|
|
90
|
+
"branches": {
|
|
91
|
+
"all": ordered_all,
|
|
92
|
+
"current": current_branch,
|
|
93
|
+
"default": default_branch,
|
|
94
|
+
"others": other_branches,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
except GitOperationError as e:
|
|
98
|
+
raise GitServiceError(f"Failed to get branch information: {e}") from e
|
|
99
|
+
|
|
100
|
+
def get_file_lines(
|
|
101
|
+
self, file_path: str, start_line: int, end_line: int
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
"""Get specific lines from a file with validation.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
file_path: Path to file
|
|
107
|
+
start_line: Starting line number
|
|
108
|
+
end_line: Ending line number
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
File lines data
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
GitServiceError: If operation fails
|
|
115
|
+
"""
|
|
116
|
+
# Validation
|
|
117
|
+
if start_line < 1 or end_line < start_line:
|
|
118
|
+
raise GitServiceError("Invalid line range")
|
|
119
|
+
|
|
120
|
+
if end_line - start_line > 100:
|
|
121
|
+
raise GitServiceError("Line range too large (max 100 lines)")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
lines = self.repo.get_file_lines(file_path, start_line, end_line)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"status": "ok",
|
|
128
|
+
"file_path": file_path,
|
|
129
|
+
"start_line": start_line,
|
|
130
|
+
"end_line": end_line,
|
|
131
|
+
"lines": lines,
|
|
132
|
+
"line_count": len(lines),
|
|
133
|
+
}
|
|
134
|
+
except GitOperationError as e:
|
|
135
|
+
raise GitServiceError(f"Failed to get file lines: {e}") from e
|