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,525 @@
1
+ """Git diff parser for converting unified diff format to side-by-side structure."""
2
+
3
+ import logging
4
+ import os
5
+ import subprocess
6
+ from typing import Any, Optional
7
+
8
+ from unidiff import Hunk, PatchedFile, PatchSet
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _get_file_line_count(file_path: str) -> Optional[int]:
14
+ """Get the number of lines in a file using wc -l.
15
+
16
+ Args:
17
+ file_path: Path to the file to count lines in
18
+
19
+ Returns:
20
+ Number of lines in the file, or None if file doesn't exist or count fails
21
+ """
22
+ try:
23
+ if not file_path or not os.path.isfile(file_path):
24
+ return None
25
+
26
+ result = subprocess.run(
27
+ ["wc", "-l", file_path], capture_output=True, text=True, timeout=5
28
+ )
29
+
30
+ if result.returncode == 0:
31
+ # wc -l output format: " 123 filename"
32
+ line_count = int(result.stdout.strip().split()[0])
33
+ return line_count
34
+ else:
35
+ logger.warning(f"wc command failed for {file_path}: {result.stderr}")
36
+ return None
37
+
38
+ except (subprocess.TimeoutExpired, ValueError, IndexError, OSError) as e:
39
+ logger.warning(f"Failed to count lines in {file_path}: {e}")
40
+ return None
41
+
42
+
43
+ class DiffParseError(Exception):
44
+ """Exception raised when diff parsing fails."""
45
+
46
+ pass
47
+
48
+
49
+ def parse_git_diff(diff_text: str) -> list[dict[str, Any]]:
50
+ """Parse git diff output into structured side-by-side format.
51
+
52
+ Args:
53
+ diff_text: Raw git diff output in unified format
54
+
55
+ Returns:
56
+ List of file dictionaries with structured diff data
57
+
58
+ Raises:
59
+ DiffParseError: If parsing fails
60
+ """
61
+ try:
62
+ if not diff_text.strip():
63
+ return []
64
+
65
+ # Parse using unidiff library
66
+ patch_set = PatchSet(diff_text)
67
+
68
+ files = []
69
+ for patched_file in patch_set:
70
+ file_data = _parse_file(patched_file)
71
+ files.append(file_data)
72
+
73
+ return files
74
+
75
+ except Exception as e:
76
+ logger.error(f"Failed to parse diff: {e}")
77
+ raise DiffParseError(f"Diff parsing failed: {e}") from e
78
+
79
+
80
+ def _parse_file(patched_file: PatchedFile) -> dict[str, Any]:
81
+ """Parse a single file's diff data.
82
+
83
+ Args:
84
+ patched_file: PatchedFile object from unidiff
85
+
86
+ Returns:
87
+ Dictionary containing file metadata and hunks
88
+ """
89
+ # Determine file status
90
+ if patched_file.is_added_file:
91
+ status = "added"
92
+ elif patched_file.is_removed_file:
93
+ status = "deleted"
94
+ elif patched_file.is_modified_file:
95
+ status = "modified"
96
+ elif patched_file.source_file != patched_file.target_file:
97
+ status = "renamed"
98
+ else:
99
+ status = "modified"
100
+
101
+ # Calculate total additions and deletions
102
+ total_additions = 0
103
+ total_deletions = 0
104
+ for hunk in patched_file:
105
+ total_additions += hunk.added
106
+ total_deletions += hunk.removed
107
+
108
+ # Clean up file paths by removing a/ and b/ prefixes
109
+ target_path = patched_file.target_file or patched_file.source_file
110
+ source_path = patched_file.source_file
111
+
112
+ # Remove a/ and b/ prefixes commonly found in git diffs
113
+ if target_path and target_path.startswith(("a/", "b/")):
114
+ target_path = target_path[2:]
115
+ if source_path and source_path.startswith(("a/", "b/")):
116
+ source_path = source_path[2:]
117
+
118
+ # Get line count for the current file (target_path)
119
+ line_count = _get_file_line_count(target_path) if target_path else None
120
+
121
+ file_data: dict[str, Any] = {
122
+ "path": target_path,
123
+ "old_path": source_path,
124
+ "status": status,
125
+ "additions": total_additions,
126
+ "deletions": total_deletions,
127
+ "changes": total_additions + total_deletions,
128
+ "line_count": line_count,
129
+ "hunks": [],
130
+ }
131
+
132
+ # Parse each hunk
133
+ for hunk in patched_file:
134
+ hunk_data = _parse_hunk(hunk)
135
+ file_data["hunks"].append(hunk_data)
136
+
137
+ return file_data
138
+
139
+
140
+ def _parse_hunk(hunk: Hunk) -> dict[str, Any]:
141
+ """Parse a single hunk into side-by-side structure.
142
+
143
+ Args:
144
+ hunk: Hunk object from unidiff
145
+
146
+ Returns:
147
+ Dictionary containing hunk metadata and side-by-side lines
148
+ """
149
+ hunk_data: dict[str, Any] = {
150
+ "old_start": hunk.source_start,
151
+ "old_count": hunk.source_length,
152
+ "new_start": hunk.target_start,
153
+ "new_count": hunk.target_length,
154
+ "section_header": hunk.section_header or "",
155
+ "lines": [],
156
+ }
157
+
158
+ # Convert linear hunk into side-by-side structure
159
+ old_line_num = hunk.source_start
160
+ new_line_num = hunk.target_start
161
+
162
+ hunk_lines = list(hunk) # Convert to list for lookahead
163
+ i = 0
164
+ while i < len(hunk_lines):
165
+ line = hunk_lines[i]
166
+
167
+ # Check if next line is a "no newline" marker
168
+ next_line_is_no_newline = (
169
+ i + 1 < len(hunk_lines) and hunk_lines[i + 1].line_type == "\\"
170
+ )
171
+
172
+ # Parse the current line (skip "no newline" markers)
173
+ if line.line_type != "\\":
174
+ line_data = _parse_line(
175
+ line, old_line_num, new_line_num, next_line_is_no_newline
176
+ )
177
+ hunk_data["lines"].append(line_data)
178
+
179
+ # Update line numbers based on line type
180
+ if line.line_type == " ": # Context line
181
+ old_line_num += 1
182
+ new_line_num += 1
183
+ elif line.line_type == "-": # Deletion
184
+ old_line_num += 1
185
+ elif line.line_type == "+": # Addition
186
+ new_line_num += 1
187
+
188
+ i += 1
189
+
190
+ return hunk_data
191
+
192
+
193
+ def _parse_line(
194
+ line: Any, old_line_num: int, new_line_num: int, missing_newline: bool = False
195
+ ) -> dict[str, Any]:
196
+ """Parse a single diff line.
197
+
198
+ Args:
199
+ line: Line object from unidiff
200
+ old_line_num: Current old file line number
201
+ new_line_num: Current new file line number
202
+ missing_newline: Whether this line is missing a newline at end
203
+
204
+ Returns:
205
+ Dictionary containing line data for side-by-side view
206
+ """
207
+ # Determine line type
208
+ if line.line_type == " ":
209
+ line_type = "context"
210
+ old_num = old_line_num
211
+ new_num = new_line_num
212
+ elif line.line_type == "-":
213
+ line_type = "deletion"
214
+ old_num = old_line_num
215
+ new_num = None
216
+ elif line.line_type == "+":
217
+ line_type = "addition"
218
+ old_num = None
219
+ new_num = new_line_num
220
+ else:
221
+ line_type = "context"
222
+ old_num = old_line_num
223
+ new_num = new_line_num
224
+
225
+ return {
226
+ "type": line_type,
227
+ "old_line_num": old_num,
228
+ "new_line_num": new_num,
229
+ # Preserve leading whitespace; remove only a single trailing newline/carriage return
230
+ "content": line.value.rstrip("\r\n"),
231
+ "missing_newline": missing_newline,
232
+ }
233
+
234
+
235
+ def create_side_by_side_lines(hunks: list[dict[str, Any]]) -> list[dict[str, Any]]:
236
+ """Convert hunks into side-by-side line pairs for rendering.
237
+
238
+ This function takes the parsed hunks and creates a structure optimized
239
+ for side-by-side rendering, handling cases where additions and deletions
240
+ don't match up 1:1.
241
+
242
+ Args:
243
+ hunks: List of parsed hunk dictionaries
244
+
245
+ Returns:
246
+ List of line pair dictionaries for side-by-side rendering
247
+ """
248
+ side_by_side_lines = []
249
+
250
+ for hunk in hunks:
251
+ # Always add hunk header to ensure proper separation
252
+ side_by_side_lines.append(
253
+ {
254
+ "type": "hunk_header",
255
+ "content": hunk.get("section_header", ""),
256
+ "old_start": hunk["old_start"],
257
+ "new_start": hunk["new_start"],
258
+ }
259
+ )
260
+
261
+ # Group consecutive additions and deletions for better alignment
262
+ i = 0
263
+ while i < len(hunk["lines"]):
264
+ line = hunk["lines"][i]
265
+
266
+ if line["type"] == "context":
267
+ # Context lines appear on both sides
268
+ side_by_side_lines.append(
269
+ {
270
+ "type": "context",
271
+ "left": {
272
+ "line_num": line["old_line_num"],
273
+ "content": line["content"],
274
+ "missing_newline": line.get("missing_newline", False),
275
+ },
276
+ "right": {
277
+ "line_num": line["new_line_num"],
278
+ "content": line["content"],
279
+ "missing_newline": line.get("missing_newline", False),
280
+ },
281
+ }
282
+ )
283
+ i += 1
284
+
285
+ elif line["type"] == "deletion":
286
+ # Look ahead for corresponding additions
287
+ deletions = []
288
+ additions = []
289
+
290
+ # Collect consecutive deletions
291
+ while i < len(hunk["lines"]) and hunk["lines"][i]["type"] == "deletion":
292
+ deletions.append(hunk["lines"][i])
293
+ i += 1
294
+
295
+ # Collect consecutive additions
296
+ while i < len(hunk["lines"]) and hunk["lines"][i]["type"] == "addition":
297
+ additions.append(hunk["lines"][i])
298
+ i += 1
299
+
300
+ # Create side-by-side pairs
301
+ max_lines = max(len(deletions), len(additions))
302
+ for j in range(max_lines):
303
+ left_line = deletions[j] if j < len(deletions) else None
304
+ right_line = additions[j] if j < len(additions) else None
305
+
306
+ side_by_side_lines.append(
307
+ {
308
+ "type": "change",
309
+ "left": {
310
+ "line_num": (
311
+ left_line["old_line_num"] if left_line else None
312
+ ),
313
+ "content": left_line["content"] if left_line else "",
314
+ "type": "deletion" if left_line else "empty",
315
+ "missing_newline": (
316
+ left_line.get("missing_newline", False)
317
+ if left_line
318
+ else False
319
+ ),
320
+ },
321
+ "right": {
322
+ "line_num": (
323
+ right_line["new_line_num"] if right_line else None
324
+ ),
325
+ "content": right_line["content"] if right_line else "",
326
+ "type": "addition" if right_line else "empty",
327
+ "missing_newline": (
328
+ right_line.get("missing_newline", False)
329
+ if right_line
330
+ else False
331
+ ),
332
+ },
333
+ }
334
+ )
335
+
336
+ elif line["type"] == "addition":
337
+ # Handle standalone addition (no preceding deletion)
338
+ side_by_side_lines.append(
339
+ {
340
+ "type": "change",
341
+ "left": {
342
+ "line_num": None,
343
+ "content": "",
344
+ "type": "empty",
345
+ "missing_newline": False,
346
+ },
347
+ "right": {
348
+ "line_num": line["new_line_num"],
349
+ "content": line["content"],
350
+ "type": "addition",
351
+ "missing_newline": line.get("missing_newline", False),
352
+ },
353
+ }
354
+ )
355
+ i += 1
356
+ else:
357
+ i += 1
358
+
359
+ return side_by_side_lines
360
+
361
+
362
+ def parse_git_diff_for_rendering(diff_text: str) -> list[dict[str, Any]]:
363
+ """Parse git diff output into side-by-side structure optimized for rendering.
364
+
365
+ This is the main method to use for converting git diff output into the final
366
+ structure needed for frontend rendering. Each file contains hunks, and each
367
+ hunk contains side-by-side line pairs ready for display.
368
+
369
+ Args:
370
+ diff_text: Raw git diff output in unified format
371
+
372
+ Returns:
373
+ List of files with side-by-side structure:
374
+ [
375
+ {
376
+ "path": "file.js",
377
+ "old_path": "file.js",
378
+ "status": "modified",
379
+ "additions": 5,
380
+ "deletions": 2,
381
+ "changes": 7,
382
+ "line_count": 150,
383
+ "hunks": [
384
+ {
385
+ "old_start": 10, "old_count": 5,
386
+ "new_start": 10, "new_count": 6,
387
+ "section_header": "function example()",
388
+ "lines": [
389
+ {
390
+ "type": "context|change|hunk_header",
391
+ "left": {"line_num": 10, "content": "...", "type": "context"},
392
+ "right": {"line_num": 10, "content": "...", "type": "context"}
393
+ }
394
+ ]
395
+ }
396
+ ]
397
+ }
398
+ ]
399
+
400
+ Raises:
401
+ DiffParseError: If parsing fails
402
+ """
403
+ try:
404
+ # First parse into intermediate structure
405
+ parsed_files = parse_git_diff(diff_text)
406
+
407
+ # Convert each file to side-by-side structure
408
+ rendered_files = []
409
+ for file_data in parsed_files:
410
+ # Convert hunks to side-by-side lines
411
+ side_by_side_lines = create_side_by_side_lines(file_data["hunks"])
412
+
413
+ # Group side-by-side lines back into hunks for rendering
414
+ rendered_hunks = _group_lines_into_hunks(
415
+ side_by_side_lines, file_data["hunks"]
416
+ )
417
+
418
+ rendered_file = {
419
+ "path": file_data["path"],
420
+ "old_path": file_data["old_path"],
421
+ "status": file_data["status"],
422
+ "additions": file_data["additions"],
423
+ "deletions": file_data["deletions"],
424
+ "changes": file_data["changes"],
425
+ "line_count": file_data["line_count"],
426
+ "hunks": rendered_hunks,
427
+ }
428
+
429
+ rendered_files.append(rendered_file)
430
+
431
+ return rendered_files
432
+
433
+ except Exception as e:
434
+ logger.error(f"Failed to parse diff for rendering: {e}")
435
+ raise DiffParseError(f"Diff parsing for rendering failed: {e}") from e
436
+
437
+
438
+ def _group_lines_into_hunks(
439
+ side_by_side_lines: list[dict[str, Any]], original_hunks: list[dict[str, Any]]
440
+ ) -> list[dict[str, Any]]:
441
+ """Group side-by-side lines back into hunks for structured rendering.
442
+
443
+ Args:
444
+ side_by_side_lines: List of side-by-side line pairs
445
+ original_hunks: Original hunk metadata for reference
446
+
447
+ Returns:
448
+ List of hunks with side-by-side lines grouped appropriately
449
+ """
450
+ if not side_by_side_lines:
451
+ return []
452
+
453
+ rendered_hunks = []
454
+ current_hunk = None
455
+ hunk_index = 0
456
+
457
+ for line_pair in side_by_side_lines:
458
+ # Start a new hunk when we encounter a hunk header
459
+ if line_pair["type"] == "hunk_header":
460
+ if current_hunk is not None:
461
+ rendered_hunks.append(current_hunk)
462
+
463
+ # Get original hunk metadata if available
464
+ original_hunk = (
465
+ original_hunks[hunk_index] if hunk_index < len(original_hunks) else {}
466
+ )
467
+
468
+ current_hunk = {
469
+ "old_start": line_pair["old_start"],
470
+ "old_count": original_hunk.get("old_count", 0),
471
+ "new_start": line_pair["new_start"],
472
+ "new_count": original_hunk.get("new_count", 0),
473
+ "section_header": line_pair["content"],
474
+ "lines": [],
475
+ }
476
+ hunk_index += 1
477
+
478
+ else:
479
+ # If we haven't started a hunk yet, create a default one
480
+ if current_hunk is None:
481
+ original_hunk = original_hunks[0] if original_hunks else {}
482
+ current_hunk = {
483
+ "old_start": original_hunk.get("old_start", 1),
484
+ "old_count": original_hunk.get("old_count", 0),
485
+ "new_start": original_hunk.get("new_start", 1),
486
+ "new_count": original_hunk.get("new_count", 0),
487
+ "section_header": "",
488
+ "lines": [],
489
+ }
490
+
491
+ # Add the line pair to current hunk
492
+ current_hunk["lines"].append(line_pair)
493
+
494
+ # Don't forget the last hunk
495
+ if current_hunk is not None:
496
+ rendered_hunks.append(current_hunk)
497
+
498
+ return rendered_hunks
499
+
500
+
501
+ def get_file_summary(files: list[dict[str, Any]]) -> dict[str, Any]:
502
+ """Generate summary statistics for a set of parsed diff files.
503
+
504
+ Args:
505
+ files: List of parsed file dictionaries
506
+
507
+ Returns:
508
+ Dictionary containing summary statistics
509
+ """
510
+ total_files = len(files)
511
+ total_additions = sum(file_data["additions"] for file_data in files)
512
+ total_deletions = sum(file_data["deletions"] for file_data in files)
513
+
514
+ files_by_status: dict[str, int] = {}
515
+ for file_data in files:
516
+ status = file_data["status"]
517
+ files_by_status[status] = files_by_status.get(status, 0) + 1
518
+
519
+ return {
520
+ "total_files": total_files,
521
+ "total_additions": total_additions,
522
+ "total_deletions": total_deletions,
523
+ "total_changes": total_additions + total_deletions,
524
+ "files_by_status": files_by_status,
525
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "status": {
3
+ "status": "ok",
4
+ "git_available": true,
5
+ "current_branch": "feature/new-ui",
6
+ "files_changed": 4,
7
+ "repository_path": "/Users/demo/project",
8
+ "is_clean": false
9
+ },
10
+ "diffs": [
11
+ {
12
+ "file": "src/components/Header.jsx",
13
+ "additions": 12,
14
+ "deletions": 3,
15
+ "changes": 15,
16
+ "status": "modified",
17
+ "content": "@@ -1,8 +1,17 @@\n import React from 'react';\n+import { useState } from 'react';\n \n const Header = () => {\n+ const [isMenuOpen, setIsMenuOpen] = useState(false);\n+\n return (\n- <header className=\"header\">\n- <h1>My App</h1>\n+ <header className=\"bg-white shadow-sm border-b\">\n+ <div className=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\">\n+ <div className=\"flex justify-between items-center h-16\">\n+ <h1 className=\"text-xl font-semibold text-gray-900\">My App</h1>\n+ <nav className=\"flex space-x-4\">\n+ <button onClick={() => setIsMenuOpen(!isMenuOpen)}>Menu</button>\n+ </nav>\n+ </div>\n+ </div>\n </header>\n );\n };"
18
+ },
19
+ {
20
+ "file": "src/styles/globals.css",
21
+ "additions": 8,
22
+ "deletions": 2,
23
+ "changes": 10,
24
+ "status": "modified",
25
+ "content": "@@ -1,6 +1,12 @@\n body {\n- margin: 0;\n- font-family: Arial, sans-serif;\n+ margin: 0;\n+ padding: 0;\n+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;\n+ line-height: 1.6;\n+ color: #333;\n }\n \n+* {\n+ box-sizing: border-box;\n+}\n+\n .container {\n max-width: 1200px;\n margin: 0 auto;"
26
+ },
27
+ {
28
+ "file": "package.json",
29
+ "additions": 4,
30
+ "deletions": 1,
31
+ "changes": 5,
32
+ "status": "modified",
33
+ "content": "@@ -8,7 +8,10 @@\n },\n \"dependencies\": {\n \"react\": \"^18.2.0\",\n- \"react-dom\": \"^18.2.0\"\n+ \"react-dom\": \"^18.2.0\",\n+ \"tailwindcss\": \"^3.3.0\",\n+ \"autoprefixer\": \"^10.4.14\",\n+ \"postcss\": \"^8.4.24\"\n },\n \"devDependencies\": {\n \"@types/react\": \"^18.2.15\","
34
+ },
35
+ {
36
+ "file": "README.md",
37
+ "additions": 15,
38
+ "deletions": 5,
39
+ "changes": 20,
40
+ "status": "modified",
41
+ "content": "@@ -1,8 +1,18 @@\n-# My Project\n+# My Awesome Project\n \n-A simple web application.\n+A modern web application built with React and Tailwind CSS.\n \n ## Installation\n \n+```bash\nnpm install\n```\n+\n+## Development\n+\n+```bash\nnpm run dev\n```\n+\n ## Usage\n \n-Run the application with `npm start`.\n+Open your browser and navigate to `http://localhost:3000`."
42
+ }
43
+ ]
44
+ }