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.
- codetour_cli/__init__.py +9 -0
- codetour_cli/cli.py +2087 -0
- codetour_cli/config.py +144 -0
- codetour_cli/description_validator.py +197 -0
- codetour_cli/detection.py +91 -0
- codetour_cli/discovery.py +226 -0
- codetour_cli/lint.py +334 -0
- codetour_cli/logging/__init__.py +6 -0
- codetour_cli/logging/setup.py +63 -0
- codetour_cli/migration/__init__.py +6 -0
- codetour_cli/migration/direct.py +385 -0
- codetour_cli/migration/git_diff.py +418 -0
- codetour_cli/migration/stepwise.py +400 -0
- codetour_cli/review_parser.py +348 -0
- codetour_cli/schema_models.py +203 -0
- codetour_cli/tour/__init__.py +6 -0
- codetour_cli/tour/schema.py +255 -0
- codetour_cli/tour/updater.py +428 -0
- codetour_cli-0.1.0.dist-info/METADATA +47 -0
- codetour_cli-0.1.0.dist-info/RECORD +24 -0
- codetour_cli-0.1.0.dist-info/WHEEL +5 -0
- codetour_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codetour_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- codetour_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|