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.
@@ -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