codetour-cli 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,418 @@
1
+ """
2
+ Git diff parsing module.
3
+
4
+ Parses Git diff output to extract line movement information, including hunk headers,
5
+ file renames, and content changes.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import List, Optional, Dict, Tuple
10
+ import re
11
+
12
+ import git
13
+ import structlog
14
+
15
+ logger = structlog.get_logger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class HunkHeader:
20
+ """
21
+ Represents a Git diff hunk header.
22
+
23
+ Format: @@ -42,5 +50,5 @@
24
+ Meaning: old lines 42-46 → new lines 50-54
25
+ """
26
+
27
+ old_start: int # Starting line in old file (1-indexed)
28
+ old_count: int # Number of lines in old file
29
+ new_start: int # Starting line in new file (1-indexed)
30
+ new_count: int # Number of lines in new file
31
+
32
+ @property
33
+ def old_end(self) -> int:
34
+ """Last line number in old file (inclusive)."""
35
+ return self.old_start + self.old_count - 1 if self.old_count > 0 else self.old_start
36
+
37
+ @property
38
+ def new_end(self) -> int:
39
+ """Last line number in new file (inclusive)."""
40
+ return self.new_start + self.new_count - 1 if self.new_count > 0 else self.new_start
41
+
42
+ @property
43
+ def line_delta(self) -> int:
44
+ """Net change in line count (positive = lines added, negative = lines removed)."""
45
+ return self.new_count - self.old_count
46
+
47
+
48
+ @dataclass
49
+ class FileDiff:
50
+ """
51
+ Represents changes to a single file between two commits.
52
+ """
53
+
54
+ old_path: Optional[str] # None if file is new
55
+ new_path: Optional[str] # None if file is deleted
56
+ is_renamed: bool
57
+ is_deleted: bool
58
+ is_new: bool
59
+ hunks: List[HunkHeader]
60
+ similarity_index: Optional[int] = None # For renames (0-100)
61
+
62
+ @property
63
+ def path(self) -> str:
64
+ """Get the most relevant path (new path if exists, else old path)."""
65
+ return self.new_path or self.old_path or "<unknown>"
66
+
67
+
68
+ @dataclass
69
+ class LineMapping:
70
+ """
71
+ Represents a mapping of a line from old position to new position.
72
+ """
73
+
74
+ old_line: int
75
+ new_line: Optional[int] # None if line was deleted
76
+ confidence: float # 0.0-1.0
77
+ method: str # 'git_tracked', 'hunk_offset', 'fuzzy_match', 'no_match'
78
+ explanation: str
79
+
80
+
81
+ class GitDiffParser:
82
+ """
83
+ Parses Git diffs to extract line movement information.
84
+
85
+ Uses Git's built-in diff algorithms (myers, patience, histogram) to track
86
+ line changes between commits.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ repo: git.Repo,
92
+ algorithm: str = "histogram"
93
+ ):
94
+ """
95
+ Initialize the parser.
96
+
97
+ Args:
98
+ repo: GitPython repository object
99
+ algorithm: Diff algorithm ('myers', 'patience', 'histogram')
100
+ """
101
+ self.repo = repo
102
+ self.algorithm = algorithm
103
+ self.log = logger.bind(repo=repo.working_dir, algorithm=algorithm)
104
+
105
+ def parse_hunk_header(self, line: str) -> Optional[HunkHeader]:
106
+ """
107
+ Parse a hunk header line.
108
+
109
+ Format: @@ -42,5 +50,5 @@ optional context
110
+ Returns: HunkHeader(old_start=42, old_count=5, new_start=50, new_count=5)
111
+
112
+ Args:
113
+ line: Hunk header line from diff
114
+
115
+ Returns:
116
+ Parsed HunkHeader or None if line is not a valid header
117
+ """
118
+ match = re.match(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@', line)
119
+ if not match:
120
+ return None
121
+
122
+ old_start = int(match.group(1))
123
+ old_count = int(match.group(2)) if match.group(2) else 1
124
+ new_start = int(match.group(3))
125
+ new_count = int(match.group(4)) if match.group(4) else 1
126
+
127
+ return HunkHeader(
128
+ old_start=old_start,
129
+ old_count=old_count,
130
+ new_start=new_start,
131
+ new_count=new_count
132
+ )
133
+
134
+ def get_file_diff(
135
+ self,
136
+ commit_a, # str or git.Commit
137
+ commit_b, # str or git.Commit
138
+ file_path: str
139
+ ) -> Optional[FileDiff]:
140
+ """
141
+ Get diff information for a specific file between two commits.
142
+
143
+ Args:
144
+ commit_a: Source commit SHA or Commit object
145
+ commit_b: Target commit SHA or Commit object
146
+ file_path: Path to the file
147
+
148
+ Returns:
149
+ FileDiff object or None if file unchanged
150
+ """
151
+ # Convert Commit objects to strings
152
+ commit_a_str = commit_a.hexsha if hasattr(commit_a, 'hexsha') else commit_a
153
+ commit_b_str = commit_b.hexsha if hasattr(commit_b, 'hexsha') else commit_b
154
+
155
+ self.log.debug(
156
+ "getting_file_diff",
157
+ commit_a=commit_a_str[:8],
158
+ commit_b=commit_b_str[:8],
159
+ file=file_path
160
+ )
161
+
162
+ # Get diff with rename detection and specified algorithm
163
+ diff_output = self.repo.git.diff(
164
+ commit_a_str,
165
+ commit_b_str,
166
+ file_path,
167
+ M=True, # Rename detection
168
+ unified=0, # No context lines (just hunks)
169
+ diff_algorithm=self.algorithm
170
+ )
171
+
172
+ if not diff_output:
173
+ self.log.debug("no_changes", file=file_path)
174
+ return None
175
+
176
+ return self._parse_diff_output(diff_output, file_path)
177
+
178
+ def get_all_file_diffs(
179
+ self,
180
+ commit_a, # str or git.Commit
181
+ commit_b, # str or git.Commit
182
+ ) -> Dict[str, FileDiff]:
183
+ """
184
+ Get diff information for all files between two commits.
185
+
186
+ Args:
187
+ commit_a: Source commit SHA or Commit object
188
+ commit_b: Target commit SHA or Commit object
189
+
190
+ Returns:
191
+ Dictionary mapping file paths to FileDiff objects
192
+ """
193
+ # Convert Commit objects to strings
194
+ commit_a_str = commit_a.hexsha if hasattr(commit_a, 'hexsha') else commit_a
195
+ commit_b_str = commit_b.hexsha if hasattr(commit_b, 'hexsha') else commit_b
196
+
197
+ self.log.info(
198
+ "getting_all_diffs",
199
+ commit_a=commit_a_str[:8],
200
+ commit_b=commit_b_str[:8]
201
+ )
202
+
203
+ # Get diff for all files
204
+ diff_output = self.repo.git.diff(
205
+ commit_a_str,
206
+ commit_b_str,
207
+ M=True, # Rename detection
208
+ unified=0, # No context lines
209
+ diff_algorithm=self.algorithm,
210
+ name_status=False # We want full diff, not just status
211
+ )
212
+
213
+ return self._parse_multi_file_diff(diff_output)
214
+
215
+ def _parse_diff_output(
216
+ self,
217
+ diff_output: str,
218
+ file_path: str
219
+ ) -> FileDiff:
220
+ """
221
+ Parse raw diff output for a single file.
222
+
223
+ Args:
224
+ diff_output: Raw git diff output
225
+ file_path: File path
226
+
227
+ Returns:
228
+ FileDiff object
229
+ """
230
+ lines = diff_output.split('\n')
231
+ hunks: List[HunkHeader] = []
232
+
233
+ old_path = file_path
234
+ new_path = file_path
235
+ is_renamed = False
236
+ is_deleted = False
237
+ is_new = False
238
+ similarity_index = None
239
+
240
+ for line in lines:
241
+ # Check for rename
242
+ if line.startswith('similarity index'):
243
+ match = re.match(r'similarity index (\d+)%', line)
244
+ if match:
245
+ similarity_index = int(match.group(1))
246
+ is_renamed = True
247
+
248
+ # Check for file deletion
249
+ elif line.startswith('deleted file'):
250
+ is_deleted = True
251
+ new_path = None
252
+
253
+ # Check for new file
254
+ elif line.startswith('new file'):
255
+ is_new = True
256
+ old_path = None
257
+
258
+ # Check for rename paths
259
+ elif line.startswith('rename from'):
260
+ old_path = line.replace('rename from ', '')
261
+ elif line.startswith('rename to'):
262
+ new_path = line.replace('rename to ', '')
263
+
264
+ # Parse hunk headers
265
+ elif line.startswith('@@'):
266
+ hunk = self.parse_hunk_header(line)
267
+ if hunk:
268
+ hunks.append(hunk)
269
+
270
+ return FileDiff(
271
+ old_path=old_path,
272
+ new_path=new_path,
273
+ is_renamed=is_renamed,
274
+ is_deleted=is_deleted,
275
+ is_new=is_new,
276
+ hunks=hunks,
277
+ similarity_index=similarity_index
278
+ )
279
+
280
+ def _parse_multi_file_diff(self, diff_output: str) -> Dict[str, FileDiff]:
281
+ """
282
+ Parse diff output for multiple files.
283
+
284
+ Args:
285
+ diff_output: Raw git diff output for multiple files
286
+
287
+ Returns:
288
+ Dictionary mapping file paths to FileDiff objects
289
+ """
290
+ # Split by file boundaries (diff --git lines)
291
+ file_diffs: Dict[str, FileDiff] = {}
292
+ current_file_lines: List[str] = []
293
+ current_file_path: Optional[str] = None
294
+
295
+ for line in diff_output.split('\n'):
296
+ if line.startswith('diff --git'):
297
+ # Process previous file if exists
298
+ if current_file_path and current_file_lines:
299
+ file_diff = self._parse_diff_output(
300
+ '\n'.join(current_file_lines),
301
+ current_file_path
302
+ )
303
+ file_diffs[file_diff.path] = file_diff
304
+
305
+ # Start new file
306
+ # Format: diff --git a/path/to/file b/path/to/file
307
+ match = re.match(r'diff --git a/(.*) b/(.*)', line)
308
+ if match:
309
+ current_file_path = match.group(2) # Use 'b' path (new)
310
+ current_file_lines = [line]
311
+ else:
312
+ current_file_lines.append(line)
313
+
314
+ # Process last file
315
+ if current_file_path and current_file_lines:
316
+ file_diff = self._parse_diff_output(
317
+ '\n'.join(current_file_lines),
318
+ current_file_path
319
+ )
320
+ file_diffs[file_diff.path] = file_diff
321
+
322
+ self.log.info("parsed_diffs", num_files=len(file_diffs))
323
+ return file_diffs
324
+
325
+ def map_line_through_hunks(
326
+ self,
327
+ line_number: int,
328
+ hunks: List[HunkHeader]
329
+ ) -> LineMapping:
330
+ """
331
+ Map a line number from old file to new file using hunk headers.
332
+
333
+ This implements the "git_tracked" method of line tracking.
334
+
335
+ Args:
336
+ line_number: Line number in old file (1-indexed)
337
+ hunks: List of hunk headers from diff
338
+
339
+ Returns:
340
+ LineMapping with new line number and confidence
341
+ """
342
+ if not hunks:
343
+ # No changes, line stays the same
344
+ return LineMapping(
345
+ old_line=line_number,
346
+ new_line=line_number,
347
+ confidence=1.0,
348
+ method='no_changes',
349
+ explanation='No hunks found - file unchanged'
350
+ )
351
+
352
+ # Sort hunks by old_start
353
+ sorted_hunks = sorted(hunks, key=lambda h: h.old_start)
354
+
355
+ current_line = line_number
356
+ cumulative_delta = 0
357
+
358
+ for hunk in sorted_hunks:
359
+ if line_number < hunk.old_start:
360
+ # Line is before this hunk, apply accumulated delta
361
+ new_line = current_line + cumulative_delta
362
+ return LineMapping(
363
+ old_line=line_number,
364
+ new_line=new_line,
365
+ confidence=1.0,
366
+ method='hunk_offset',
367
+ explanation=f'Line before hunk at {hunk.old_start}, offset by {cumulative_delta}'
368
+ )
369
+
370
+ elif hunk.old_start <= line_number <= hunk.old_end:
371
+ if hunk.new_count == 0:
372
+ # Pure deletion hunk: no corresponding new lines at all.
373
+ # new_start is git's own degenerate sentinel here (often 0),
374
+ # not a real position -- `new_start + offset_in_hunk` would
375
+ # fabricate an invalid line number (e.g. 0) rather than
376
+ # honestly reporting "no destination exists" (ADR-0016
377
+ # QST-CONF-1). This is a real, common shape -- e.g. two
378
+ # adjacent lines swapping order, where the moved line's
379
+ # OLD position becomes a pure deletion and its content
380
+ # reappears in a separate hunk elsewhere in the same diff.
381
+ return LineMapping(
382
+ old_line=line_number,
383
+ new_line=None,
384
+ confidence=0.0,
385
+ method='line_removed',
386
+ explanation=(
387
+ f'Line was part of a pure deletion (hunk '
388
+ f'{hunk.old_start}-{hunk.old_end} contributes no new '
389
+ f'lines) -- no corresponding new position exists'
390
+ )
391
+ )
392
+
393
+ # Line is within this hunk - need content matching
394
+ # For now, we'll mark it as uncertain
395
+ offset_in_hunk = line_number - hunk.old_start
396
+ estimated_new_line = hunk.new_start + offset_in_hunk
397
+
398
+ return LineMapping(
399
+ old_line=line_number,
400
+ new_line=estimated_new_line,
401
+ confidence=0.5,
402
+ method='hunk_interior',
403
+ explanation=f'Line inside changed hunk {hunk.old_start}-{hunk.old_end}, needs content matching'
404
+ )
405
+
406
+ else:
407
+ # Line is after this hunk, accumulate delta
408
+ cumulative_delta += hunk.line_delta
409
+
410
+ # Line is after all hunks
411
+ new_line = line_number + cumulative_delta
412
+ return LineMapping(
413
+ old_line=line_number,
414
+ new_line=new_line,
415
+ confidence=1.0,
416
+ method='hunk_offset',
417
+ explanation=f'Line after all hunks, offset by {cumulative_delta}'
418
+ )