code-review-ai-cli 1.0.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.
src/git_utils.py ADDED
@@ -0,0 +1,487 @@
1
+ """
2
+ Git Utilities Module - AI Code Review
3
+ ========================================
4
+ Responsible for capturing Git diffs in different scenarios:
5
+ - Staged changes (before commit)
6
+ - Specific commits
7
+ - Differences between branches
8
+ - Working directory changes
9
+
10
+ Works with any Git repository, including TFS/Azure DevOps.
11
+ """
12
+
13
+ import subprocess
14
+ import os
15
+ from typing import Optional
16
+
17
+
18
+ class GitError(Exception):
19
+ """Exception for Git-related errors."""
20
+ pass
21
+
22
+
23
+ class GitUtils:
24
+ """Utility class for Git operations."""
25
+
26
+ def __init__(self, repo_path: Optional[str] = None):
27
+ """
28
+ Initializes the Git utility.
29
+
30
+ Args:
31
+ repo_path: Path to the repository. If None, uses the current directory.
32
+ """
33
+ self.repo_path = repo_path or os.getcwd()
34
+ self._validate_repo()
35
+
36
+ # ------------------------------------------------------------------
37
+ # Validation
38
+ # ------------------------------------------------------------------
39
+ def _validate_repo(self) -> None:
40
+ """Checks whether we are inside a valid Git repository."""
41
+ try:
42
+ self._run_git("rev-parse", "--git-dir")
43
+ except GitError:
44
+ raise GitError(
45
+ f"Directory '{self.repo_path}' is not a valid Git repository.\n"
46
+ "Make sure you are inside a Git repository."
47
+ )
48
+
49
+ # ------------------------------------------------------------------
50
+ # Internal Git commands
51
+ # ------------------------------------------------------------------
52
+ def _run_git(self, *args: str, check: bool = True) -> str:
53
+ """
54
+ Runs a git command and returns the output.
55
+
56
+ Args:
57
+ *args: Git command arguments.
58
+ check: If True, raises an exception on error.
59
+
60
+ Returns:
61
+ Command output as string.
62
+ """
63
+ cmd = ["git"] + list(args)
64
+ try:
65
+ result = subprocess.run(
66
+ cmd,
67
+ cwd=self.repo_path,
68
+ capture_output=True,
69
+ text=True,
70
+ encoding="utf-8",
71
+ errors="replace",
72
+ timeout=60,
73
+ )
74
+ if check and result.returncode != 0:
75
+ raise GitError(
76
+ f"Git command failed: {' '.join(cmd)}\n"
77
+ f"Error: {result.stderr.strip()}"
78
+ )
79
+ return result.stdout
80
+ except FileNotFoundError:
81
+ raise GitError(
82
+ "Git not found. Make sure Git is installed "
83
+ "and available in PATH."
84
+ )
85
+ except subprocess.TimeoutExpired:
86
+ raise GitError(f"Timeout executing: {' '.join(cmd)}")
87
+
88
+ # ------------------------------------------------------------------
89
+ # Repository information
90
+ # ------------------------------------------------------------------
91
+ def get_current_branch(self) -> str:
92
+ """Returns the current branch name."""
93
+ return self._run_git("branch", "--show-current").strip()
94
+
95
+ def get_repo_name(self) -> str:
96
+ """Returns the repository name."""
97
+ try:
98
+ remote_url = self._run_git("remote", "get-url", "origin").strip()
99
+ # Extract repo name from URL
100
+ name = remote_url.rstrip("/").split("/")[-1]
101
+ if name.endswith(".git"):
102
+ name = name[:-4]
103
+ return name
104
+ except GitError:
105
+ return os.path.basename(self.repo_path)
106
+
107
+ def get_remote_url(self) -> str:
108
+ """Returns the remote origin URL."""
109
+ try:
110
+ return self._run_git("remote", "get-url", "origin").strip()
111
+ except GitError:
112
+ return "(no remote configured)"
113
+
114
+ def list_branches(self, remote: bool = False) -> list[str]:
115
+ """Lists available branches."""
116
+ args = ["branch"]
117
+ if remote:
118
+ args.append("-r")
119
+ output = self._run_git(*args)
120
+ branches = []
121
+ for line in output.strip().split("\n"):
122
+ branch = line.strip().lstrip("* ").strip()
123
+ if branch and "HEAD" not in branch:
124
+ branches.append(branch)
125
+ return branches
126
+
127
+ def get_recent_commits(self, count: int = 10, branch: Optional[str] = None) -> list[dict]:
128
+ """
129
+ Returns the most recent commits.
130
+
131
+ Returns:
132
+ List of dicts with 'hash', 'short_hash', 'author', 'date', 'message'.
133
+ """
134
+ args = [
135
+ "log",
136
+ f"-{count}",
137
+ "--pretty=format:%H|%h|%an|%ai|%s",
138
+ ]
139
+ if branch:
140
+ args.append(branch)
141
+
142
+ output = self._run_git(*args)
143
+ commits = []
144
+ for line in output.strip().split("\n"):
145
+ if not line.strip():
146
+ continue
147
+ parts = line.split("|", 4)
148
+ if len(parts) == 5:
149
+ commits.append({
150
+ "hash": parts[0],
151
+ "short_hash": parts[1],
152
+ "author": parts[2],
153
+ "date": parts[3],
154
+ "message": parts[4],
155
+ })
156
+ return commits
157
+
158
+ # ------------------------------------------------------------------
159
+ # Diff capture
160
+ # ------------------------------------------------------------------
161
+ def get_staged_diff(self) -> str:
162
+ """
163
+ Captures the diff of staged files (git add).
164
+ Used for review before committing.
165
+ """
166
+ diff = self._run_git("diff", "--cached", "--no-color")
167
+ if not diff.strip():
168
+ raise GitError(
169
+ "No staged changes found.\n"
170
+ "Use 'git add <file>' to add files to staging."
171
+ )
172
+ return diff
173
+
174
+ def get_working_diff(self) -> str:
175
+ """
176
+ Captures the diff of modified files in the working directory.
177
+ (Changes not yet added to staging.)
178
+ """
179
+ diff = self._run_git("diff", "--no-color")
180
+ if not diff.strip():
181
+ raise GitError(
182
+ "No changes in working directory.\n"
183
+ "Files may already be staged (use --staged)."
184
+ )
185
+ return diff
186
+
187
+ def get_all_changes_diff(self) -> str:
188
+ """
189
+ Captures the diff of ALL changes (staged + unstaged).
190
+ """
191
+ staged = self._run_git("diff", "--cached", "--no-color", check=False)
192
+ unstaged = self._run_git("diff", "--no-color", check=False)
193
+
194
+ combined = ""
195
+ if staged.strip():
196
+ combined += f"# === STAGED CHANGES ===\n{staged}\n"
197
+ if unstaged.strip():
198
+ combined += f"# === WORKING DIRECTORY CHANGES ===\n{unstaged}\n"
199
+
200
+ if not combined.strip():
201
+ raise GitError("No changes (staged or unstaged) in the repository.")
202
+
203
+ return combined
204
+
205
+ def get_commit_diff(self, commit_hash: str) -> str:
206
+ """
207
+ Captures the diff of a specific commit.
208
+
209
+ Args:
210
+ commit_hash: Commit hash (full or abbreviated).
211
+ """
212
+ diff = self._run_git("show", commit_hash, "--no-color", "--format=")
213
+ if not diff.strip():
214
+ raise GitError(f"Commit '{commit_hash}' contains no code changes.")
215
+ return diff
216
+
217
+ def get_commit_range_diff(self, from_commit: str, to_commit: str = "HEAD") -> str:
218
+ """
219
+ Captures the diff between two commits.
220
+
221
+ Args:
222
+ from_commit: Starting commit hash.
223
+ to_commit: Ending commit hash (default: HEAD).
224
+ """
225
+ diff = self._run_git("diff", f"{from_commit}..{to_commit}", "--no-color")
226
+ if not diff.strip():
227
+ raise GitError(
228
+ f"No differences between '{from_commit}' and '{to_commit}'."
229
+ )
230
+ return diff
231
+
232
+ def get_branch_diff(self, source_branch: str, target_branch: Optional[str] = None) -> str:
233
+ """
234
+ Captures the diff between two branches.
235
+ Useful to simulate a Pull Request diff.
236
+
237
+ Args:
238
+ source_branch: Branch with changes (feature branch).
239
+ target_branch: Target branch (default: current branch).
240
+ """
241
+ if target_branch is None:
242
+ target_branch = self.get_current_branch()
243
+
244
+ # Use merge-base to get the correct diff (like a real PR)
245
+ try:
246
+ merge_base = self._run_git(
247
+ "merge-base", target_branch, source_branch
248
+ ).strip()
249
+ diff = self._run_git(
250
+ "diff", f"{merge_base}..{source_branch}", "--no-color"
251
+ )
252
+ except GitError:
253
+ # Fallback: direct diff between branches
254
+ diff = self._run_git(
255
+ "diff", f"{target_branch}..{source_branch}", "--no-color"
256
+ )
257
+
258
+ if not diff.strip():
259
+ raise GitError(
260
+ f"No differences between '{target_branch}' and '{source_branch}'."
261
+ )
262
+ return diff
263
+
264
+ def get_file_diff(self, file_path: str, staged: bool = False) -> str:
265
+ """
266
+ Captures the diff of a specific file.
267
+
268
+ Args:
269
+ file_path: File path.
270
+ staged: If True, captures diff from staging.
271
+ """
272
+ args = ["diff", "--no-color"]
273
+ if staged:
274
+ args.append("--cached")
275
+ args.append("--")
276
+ args.append(file_path)
277
+
278
+ diff = self._run_git(*args)
279
+ if not diff.strip():
280
+ raise GitError(f"No changes in file '{file_path}'.")
281
+ return diff
282
+
283
+ # ------------------------------------------------------------------
284
+ # Filters and Utilities
285
+ # ------------------------------------------------------------------
286
+ def filter_diff_additions_only(self, diff: str) -> str:
287
+ """
288
+ Removes context lines and deleted lines (-) from the diff.
289
+ Keeps only added lines (+) and structural headers needed for the LLM.
290
+
291
+ Lines kept:
292
+ - diff --git ...
293
+ - --- a/...
294
+ - +++ b/...
295
+ - @@ ... @@
296
+ - + <content>
297
+
298
+ Returns:
299
+ Filtered diff.
300
+ """
301
+ result = []
302
+ for line in diff.split("\n"):
303
+ if (
304
+ line.startswith("diff --git")
305
+ or line.startswith("--- ")
306
+ or line.startswith("+++ ")
307
+ or line.startswith("@@")
308
+ or (line.startswith("+") and not line.startswith("+++"))
309
+ ):
310
+ result.append(line)
311
+ # Context lines, deleted lines and '\ No newline' markers are discarded.
312
+ return "\n".join(result)
313
+
314
+ def _split_diff_sections(self, diff: str) -> tuple[list[list[str]], bool]:
315
+ """
316
+ Splits the diff into sections per file ("diff --git ...").
317
+
318
+ Returns:
319
+ Tuple (sections, has_file_separators).
320
+ """
321
+ lines = diff.split("\n")
322
+ has_sections = any(line.startswith("diff --git") for line in lines)
323
+ if not has_sections:
324
+ return [lines], False
325
+
326
+ sections: list[list[str]] = []
327
+ current: list[str] = []
328
+ for line in lines:
329
+ if line.startswith("diff --git") and current:
330
+ sections.append(current)
331
+ current = [line]
332
+ else:
333
+ current.append(line)
334
+ if current:
335
+ sections.append(current)
336
+ return sections, True
337
+
338
+ def limit_diff_files(self, diff: str, max_files: int = 50) -> tuple[str, bool, int]:
339
+ """
340
+ Limits the number of files in the diff ("diff --git" sections).
341
+
342
+ Returns:
343
+ Tuple (limited_diff, was_limited, omitted_files).
344
+ """
345
+ sections, has_file_sections = self._split_diff_sections(diff)
346
+ if not has_file_sections:
347
+ return diff, False, 0
348
+
349
+ total_files = len(sections)
350
+ if total_files <= max_files:
351
+ return diff, False, 0
352
+
353
+ kept_sections = sections[:max_files]
354
+ omitted_files = total_files - max_files
355
+ limited = "\n".join("\n".join(section) for section in kept_sections)
356
+ limited += (
357
+ f"\n\n... [TRUNCATED: {omitted_files} file(s) omitted. "
358
+ f"Total files in diff: {total_files}] ..."
359
+ )
360
+ return limited, True, omitted_files
361
+
362
+ def filter_diff_by_extensions(self, diff: str, extensions: list[str]) -> str:
363
+ """
364
+ Filters the diff to include only files with specific extensions.
365
+
366
+ Args:
367
+ diff: The full diff.
368
+ extensions: List of extensions (e.g., ['.py', '.js', '.cs']).
369
+ """
370
+ if not extensions:
371
+ return diff
372
+
373
+ filtered_sections = []
374
+ current_section = []
375
+ include_section = False
376
+
377
+ for line in diff.split("\n"):
378
+ if line.startswith("diff --git"):
379
+ # Save previous section if applicable
380
+ if include_section and current_section:
381
+ filtered_sections.append("\n".join(current_section))
382
+ current_section = [line]
383
+ # Check if file has an allowed extension
384
+ file_path = line.split(" b/")[-1] if " b/" in line else ""
385
+ include_section = any(file_path.endswith(ext) for ext in extensions)
386
+ else:
387
+ current_section.append(line)
388
+
389
+ # Last section
390
+ if include_section and current_section:
391
+ filtered_sections.append("\n".join(current_section))
392
+
393
+ result = "\n".join(filtered_sections)
394
+ if not result.strip():
395
+ raise GitError(
396
+ f"After filtering by extensions {extensions}, no changes remain."
397
+ )
398
+ return result
399
+
400
+ def truncate_diff(self, diff: str, max_lines: int = 2000) -> tuple[str, bool]:
401
+ """
402
+ Kept for compatibility: applies per-file truncation when
403
+ the diff contains sections in 'diff --git' format.
404
+
405
+ Returns:
406
+ Tuple (truncated_diff, was_truncated).
407
+ """
408
+ return self.truncate_diff_per_file(diff, max_lines)
409
+
410
+ def truncate_diff_per_file(self, diff: str, max_lines: int = 2000) -> tuple[str, bool]:
411
+ """
412
+ Truncates the diff per file if it exceeds the maximum lines per section.
413
+ Falls back to global truncation if no file sections are present.
414
+
415
+ Returns:
416
+ Tuple (truncated_diff, was_truncated).
417
+ """
418
+ sections, has_file_sections = self._split_diff_sections(diff)
419
+
420
+ # Fallback for diffs without file separators
421
+ if not has_file_sections:
422
+ lines = sections[0]
423
+ if len(lines) <= max_lines:
424
+ return diff, False
425
+ truncated = "\n".join(lines[:max_lines])
426
+ truncated += (
427
+ f"\n\n... [TRUNCATED: {len(lines) - max_lines} lines omitted. "
428
+ f"Total: {len(lines)} lines] ..."
429
+ )
430
+ return truncated, True
431
+
432
+ truncated_any = False
433
+ output_sections: list[str] = []
434
+ for section in sections:
435
+ if len(section) <= max_lines:
436
+ output_sections.append("\n".join(section))
437
+ continue
438
+
439
+ truncated_any = True
440
+ omitted = len(section) - max_lines
441
+ part = "\n".join(section[:max_lines])
442
+ part += (
443
+ f"\n... [TRUNCATED IN THIS FILE: {omitted} lines omitted. "
444
+ f"Original section: {len(section)} lines] ..."
445
+ )
446
+ output_sections.append(part)
447
+
448
+ return "\n".join(output_sections), truncated_any
449
+
450
+ def get_changed_files_summary(self, diff: str) -> list[dict]:
451
+ """
452
+ Extracts a summary of changed files from the diff.
453
+
454
+ Returns:
455
+ List of dicts with 'file', 'additions', 'deletions'.
456
+ """
457
+ files = []
458
+ current_file = None
459
+ additions = 0
460
+ deletions = 0
461
+
462
+ for line in diff.split("\n"):
463
+ if line.startswith("diff --git"):
464
+ if current_file:
465
+ files.append({
466
+ "file": current_file,
467
+ "additions": additions,
468
+ "deletions": deletions,
469
+ })
470
+ # Extract file name
471
+ parts = line.split(" b/")
472
+ current_file = parts[-1] if len(parts) > 1 else "unknown"
473
+ additions = 0
474
+ deletions = 0
475
+ elif line.startswith("+") and not line.startswith("+++"):
476
+ additions += 1
477
+ elif line.startswith("-") and not line.startswith("---"):
478
+ deletions += 1
479
+
480
+ if current_file:
481
+ files.append({
482
+ "file": current_file,
483
+ "additions": additions,
484
+ "deletions": deletions,
485
+ })
486
+
487
+ return files