comit-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.
comit/git.py ADDED
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Optional, List, Tuple
8
+
9
+ from comit.commit.context import ChangedFile, DiffStat, CommitContext
10
+
11
+
12
+ class GitError(Exception):
13
+ pass
14
+
15
+
16
+ class GitNotInstalledError(GitError):
17
+ pass
18
+
19
+
20
+ class NotAGitRepositoryError(GitError):
21
+ pass
22
+
23
+
24
+ class NoStagedChangesError(GitError):
25
+ pass
26
+
27
+
28
+ class GitCommitError(GitError):
29
+ pass
30
+
31
+
32
+ def _run_git_command(args: List[str], cwd: Optional[Path | str] = None) -> subprocess.CompletedProcess[str]:
33
+ cmd = ["git"] + args
34
+ try:
35
+ return subprocess.run(
36
+ cmd,
37
+ cwd=str(cwd) if cwd else None,
38
+ capture_output=True,
39
+ text=True,
40
+ encoding="utf-8",
41
+ errors="replace",
42
+ shell=False,
43
+ check=False,
44
+ )
45
+ except FileNotFoundError as exc:
46
+ raise GitNotInstalledError(
47
+ "Git is not installed or not found in system PATH. Please install Git to use Comit."
48
+ ) from exc
49
+
50
+
51
+ def is_git_repository(cwd: Optional[Path | str] = None) -> bool:
52
+ res = _run_git_command(["rev-parse", "--is-inside-work-tree"], cwd=cwd)
53
+ return res.returncode == 0 and res.stdout.strip() == "true"
54
+
55
+
56
+ def get_repository_name(cwd: Optional[Path | str] = None) -> str:
57
+ if not is_git_repository(cwd):
58
+ target_dir = Path(cwd).resolve() if cwd else Path.cwd()
59
+ return target_dir.name or "repository"
60
+
61
+ res = _run_git_command(["rev-parse", "--show-toplevel"], cwd=cwd)
62
+ if res.returncode == 0 and res.stdout.strip():
63
+ top_dir = Path(res.stdout.strip()).resolve()
64
+ return top_dir.name
65
+
66
+ target_dir = Path(cwd).resolve() if cwd else Path.cwd()
67
+ return target_dir.name or "repository"
68
+
69
+
70
+ def get_staged_diff(cwd: Optional[Path | str] = None) -> str:
71
+ if not is_git_repository(cwd):
72
+ raise NotAGitRepositoryError("Not a Git repository.")
73
+
74
+ res = _run_git_command(["diff", "--cached"], cwd=cwd)
75
+ if res.returncode != 0:
76
+ raise GitError(f"Failed to get staged diff: {res.stderr.strip()}")
77
+ return res.stdout.strip()
78
+
79
+
80
+ def get_staged_changed_files(cwd: Optional[Path | str] = None) -> List[ChangedFile]:
81
+ if not is_git_repository(cwd):
82
+ raise NotAGitRepositoryError("Not a Git repository.")
83
+
84
+ res = _run_git_command(["diff", "--cached", "--name-status"], cwd=cwd)
85
+ if res.returncode != 0:
86
+ return []
87
+
88
+ changed_files: List[ChangedFile] = []
89
+ for line in res.stdout.splitlines():
90
+ line = line.strip()
91
+ if not line:
92
+ continue
93
+ parts = line.split("\t")
94
+ if len(parts) >= 3:
95
+ raw_status = parts[0].strip()
96
+ status_code = raw_status[0].upper() if raw_status else "M"
97
+ old_path = parts[1].strip()
98
+ new_path = parts[2].strip()
99
+ changed_files.append(ChangedFile(path=new_path, status=status_code, old_path=old_path))
100
+ elif len(parts) == 2:
101
+ raw_status = parts[0].strip()
102
+ status_code = raw_status[0].upper() if raw_status else "M"
103
+ path = parts[1].strip()
104
+ changed_files.append(ChangedFile(path=path, status=status_code))
105
+ return changed_files
106
+
107
+
108
+ def get_staged_file_size(path: str, cwd: Optional[Path | str] = None) -> int:
109
+ if not is_git_repository(cwd):
110
+ return 0
111
+
112
+ res = _run_git_command(["cat-file", "-s", f":{path}"], cwd=cwd)
113
+ if res.returncode == 0 and res.stdout.strip().isdigit():
114
+ return int(res.stdout.strip())
115
+
116
+ try:
117
+ target = Path(cwd) / path if cwd else Path(path)
118
+ if target.is_file():
119
+ return target.stat().st_size
120
+ except Exception:
121
+ pass
122
+ return 0
123
+
124
+
125
+ def get_diff_stat(cwd: Optional[Path | str] = None) -> DiffStat:
126
+ if not is_git_repository(cwd):
127
+ raise NotAGitRepositoryError("Not a Git repository.")
128
+
129
+ res = _run_git_command(["diff", "--cached", "--shortstat"], cwd=cwd)
130
+ if res.returncode != 0 or not res.stdout.strip():
131
+ return DiffStat()
132
+
133
+ text = res.stdout.strip()
134
+ files_changed = 0
135
+ insertions = 0
136
+ deletions = 0
137
+
138
+ m_files = re.search(r"(\d+)\s+file", text)
139
+ if m_files:
140
+ files_changed = int(m_files.group(1))
141
+
142
+ m_ins = re.search(r"(\d+)\s+insertion", text)
143
+ if m_ins:
144
+ insertions = int(m_ins.group(1))
145
+
146
+ m_del = re.search(r"(\d+)\s+deletion", text)
147
+ if m_del:
148
+ deletions = int(m_del.group(1))
149
+
150
+ return DiffStat(
151
+ files_changed=files_changed,
152
+ insertions=insertions,
153
+ deletions=deletions,
154
+ summary_text=text,
155
+ )
156
+
157
+
158
+ def get_recent_commits(count: int = 5, cwd: Optional[Path | str] = None) -> List[str]:
159
+ if not is_git_repository(cwd):
160
+ raise NotAGitRepositoryError("Not a Git repository.")
161
+
162
+ res = _run_git_command(["log", f"-{count}", "--pretty=format:%s"], cwd=cwd)
163
+ if res.returncode != 0:
164
+ return []
165
+
166
+ output = res.stdout.strip()
167
+ if not output:
168
+ return []
169
+ return [line.strip() for line in output.splitlines() if line.strip()]
170
+
171
+
172
+ def get_git_status_short(cwd: Optional[Path | str] = None) -> str:
173
+ if not is_git_repository(cwd):
174
+ raise NotAGitRepositoryError("Not a Git repository.")
175
+
176
+ res = _run_git_command(["status", "--short"], cwd=cwd)
177
+ return res.stdout.strip()
178
+
179
+
180
+ def create_commit(message: str, cwd: Optional[Path | str] = None) -> Tuple[bool, str]:
181
+ if not is_git_repository(cwd):
182
+ raise NotAGitRepositoryError("Not a Git repository.")
183
+
184
+ clean_message = message.strip()
185
+ if not clean_message:
186
+ raise GitCommitError("Cannot create commit with an empty commit message.")
187
+
188
+ res = _run_git_command(["commit", "-m", clean_message], cwd=cwd)
189
+ if res.returncode == 0:
190
+ return True, res.stdout.strip()
191
+ return False, res.stderr.strip() or res.stdout.strip()
192
+
193
+
194
+ def get_remotes(cwd: Optional[Path | str] = None) -> List[str]:
195
+ if not is_git_repository(cwd):
196
+ raise NotAGitRepositoryError("Not a Git repository.")
197
+
198
+ res = _run_git_command(["remote"], cwd=cwd)
199
+ if res.returncode != 0:
200
+ return []
201
+ output = res.stdout.strip()
202
+ if not output:
203
+ return []
204
+ return [line.strip() for line in output.splitlines() if line.strip()]
205
+
206
+
207
+ def get_default_remote(cwd: Optional[Path | str] = None) -> Optional[str]:
208
+ remotes = get_remotes(cwd=cwd)
209
+ if "origin" in remotes:
210
+ return "origin"
211
+ return None
212
+
213
+
214
+ def get_current_branch(cwd: Optional[Path | str] = None) -> Optional[str]:
215
+ if not is_git_repository(cwd):
216
+ raise NotAGitRepositoryError("Not a Git repository.")
217
+
218
+ res = _run_git_command(["branch", "--show-current"], cwd=cwd)
219
+ if res.returncode == 0 and res.stdout.strip():
220
+ return res.stdout.strip()
221
+
222
+ res_head = _run_git_command(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
223
+ if res_head.returncode == 0:
224
+ out = res_head.stdout.strip()
225
+ if out and out != "HEAD":
226
+ return out
227
+
228
+ res_sha = _run_git_command(["rev-parse", "--short", "HEAD"], cwd=cwd)
229
+ if res_sha.returncode == 0 and res_sha.stdout.strip():
230
+ return f"HEAD (detached at {res_sha.stdout.strip()})"
231
+
232
+ return "HEAD (detached)"
233
+
234
+
235
+ def push_commit(remote: str, branch: str, cwd: Optional[Path | str] = None) -> Tuple[bool, str]:
236
+ if not is_git_repository(cwd):
237
+ raise NotAGitRepositoryError("Not a Git repository.")
238
+
239
+ res = _run_git_command(["push", remote, branch], cwd=cwd)
240
+ if res.returncode == 0:
241
+ return True, res.stdout.strip() or f"Pushed to {remote}/{branch}"
242
+
243
+ err = res.stderr.strip() or res.stdout.strip()
244
+ err_lower = err.lower()
245
+
246
+ if "rejected" in err_lower and ("fetch first" in err_lower or "behind" in err_lower or "non-fast-forward" in err_lower):
247
+ clean_err = "The remote rejected the push because the branch is behind the remote."
248
+ elif "permission denied" in err_lower or "authentication failed" in err_lower or "invalid username or password" in err_lower or "403" in err_lower or "401" in err_lower:
249
+ clean_err = "Authentication failed for the remote repository."
250
+ elif "could not resolve host" in err_lower or "connection refused" in err_lower or "fatal: unable to access" in err_lower:
251
+ clean_err = "Could not connect to the remote repository. Please check your network connection."
252
+ elif "src refspec" in err_lower or "does not match any" in err_lower:
253
+ clean_err = f"Branch '{branch}' does not exist locally."
254
+ elif "no upstream" in err_lower or "set-upstream" in err_lower:
255
+ clean_err = f"No upstream branch configured for '{branch}'."
256
+ else:
257
+ first_line = err.splitlines()[0] if err.splitlines() else "Git push failed."
258
+ clean_err = first_line
259
+
260
+ return False, clean_err
261
+
262
+
263
+ def get_commit_context(cwd: Optional[Path | str] = None, recent_commit_count: int = 5) -> CommitContext:
264
+ if not is_git_repository(cwd):
265
+ raise NotAGitRepositoryError("Not a Git repository.")
266
+
267
+ repo_name = get_repository_name(cwd=cwd)
268
+ branch = get_current_branch(cwd=cwd) or "HEAD (detached)"
269
+ changed_files = get_staged_changed_files(cwd=cwd)
270
+ diff_stat = get_diff_stat(cwd=cwd)
271
+ recent_commits = get_recent_commits(count=recent_commit_count, cwd=cwd)
272
+ staged_diff = get_staged_diff(cwd=cwd)
273
+
274
+ return CommitContext(
275
+ repository_name=repo_name,
276
+ current_branch=branch,
277
+ changed_files=changed_files,
278
+ recent_commits=recent_commits,
279
+ diff_stat=diff_stat,
280
+ staged_diff=staged_diff,
281
+ )
comit/prompts.py ADDED
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import List, Optional, Union
5
+
6
+ from comit.commit.context import CommitContext, DiffStat
7
+
8
+ MAX_DIFF_CHARS = 12000
9
+
10
+ SYSTEM_PROMPT = """You are an expert Git commit assistant.
11
+ Your task is to generate ONE concise, accurate, and high-quality Git commit message based on the provided staged diff and repository context.
12
+
13
+ Requirements:
14
+ 1. The staged diff is the ultimate source of truth. Examine the staged diff to understand the exact changes made. Do NOT invent features or mention files/changes not present in the diff.
15
+ 2. Use the repository context (repository name, current branch, changed files, statistics) to understand the intent, scope, and domain of the modifications.
16
+ 3. Examine the recent commit history to infer and match the repository's existing style:
17
+ - If the repository uses Conventional Commits (e.g. feat:, fix:, refactor:, chore:, docs:, test:, style:), adhere to Conventional Commits.
18
+ - If the repository uses another consistent style (capitalization, prefix convention, imperative vs descriptive verbs, issue references), follow that style.
19
+ - If there is no commit history or style is mixed, use standard Conventional Commits.
20
+ 4. Do not let previous commits or branch names override what is actually present in the staged diff.
21
+ 5. Provide a specific, informative summary of the changes. Avoid vague subjects such as 'update stuff', 'fix bug', 'changes', 'misc updates', or 'refactoring'. Prefer specific descriptions directly supported by the staged diff.
22
+ 6. Keep the commit message concise (single-line subject, ideally under 72 characters).
23
+ 7. Do NOT wrap the message in quotation marks.
24
+ 8. Do NOT use markdown code blocks or backticks in the response.
25
+ 9. Do NOT include explanations, greetings, preamble, or commentary (e.g. do not write "Here is the commit message:").
26
+ 10. Return ONLY the commit message text.
27
+ """
28
+
29
+
30
+ def truncate_diff(diff: str, max_chars: int = MAX_DIFF_CHARS) -> str:
31
+ if len(diff) <= max_chars:
32
+ return diff
33
+
34
+ truncated = diff[:max_chars]
35
+ last_newline = truncated.rfind("\n")
36
+ if last_newline > 0:
37
+ truncated = truncated[:last_newline]
38
+
39
+ return (
40
+ f"{truncated}\n\n"
41
+ "[... Staged diff truncated: changes were too large. Focus on the primary modifications shown above ...]"
42
+ )
43
+
44
+
45
+ def build_commit_prompt(
46
+ context: Union[CommitContext, str],
47
+ recent_commits: Optional[List[str]] = None,
48
+ avoid_messages: Optional[List[str]] = None,
49
+ ) -> str:
50
+ if isinstance(context, str):
51
+ context_obj = CommitContext(
52
+ repository_name="",
53
+ current_branch="",
54
+ changed_files=[],
55
+ recent_commits=recent_commits or [],
56
+ diff_stat=DiffStat(),
57
+ staged_diff=context,
58
+ )
59
+ else:
60
+ context_obj = context
61
+ if recent_commits and not context_obj.recent_commits:
62
+ context_obj.recent_commits = recent_commits
63
+
64
+ safe_diff = truncate_diff(context_obj.staged_diff)
65
+ sections: List[str] = []
66
+
67
+ repo_meta: List[str] = []
68
+ if context_obj.repository_name:
69
+ repo_meta.append(f"Repository: {context_obj.repository_name}")
70
+ if context_obj.current_branch:
71
+ repo_meta.append(f"Branch: {context_obj.current_branch}")
72
+ if repo_meta:
73
+ sections.append("\n".join(repo_meta))
74
+
75
+ if context_obj.changed_files:
76
+ files_lines = [f.format_line() for f in context_obj.changed_files]
77
+ sections.append("Changed files:\n" + "\n".join(files_lines))
78
+
79
+ stat_summary = context_obj.diff_stat.format_summary()
80
+ if stat_summary and stat_summary != "0 files changed":
81
+ sections.append(f"Change statistics:\n {stat_summary}")
82
+
83
+ if context_obj.recent_commits:
84
+ history_str = "\n".join(f" {c}" for c in context_obj.recent_commits[:15])
85
+ sections.append(f"Recent commits:\n{history_str}")
86
+ else:
87
+ sections.append("Recent commits:\n (No previous commits found; use Conventional Commits style)")
88
+
89
+ sections.append(f"Staged diff:\n```diff\n{safe_diff}\n```")
90
+
91
+ if avoid_messages:
92
+ avoid_str = "\n".join(f'- "{m}"' for m in avoid_messages if m and m.strip())
93
+ sections.append(
94
+ f"Previous suggestion(s) to avoid repeating:\n{avoid_str}\n\n"
95
+ "Please generate a DIFFERENT, alternative commit message that accurately describes the staged changes without repeating the previous suggestion(s)."
96
+ )
97
+ else:
98
+ sections.append("Generate ONE commit message for these changes:")
99
+
100
+ return "\n\n".join(sections)
101
+
102
+
103
+ def sanitize_commit_message(raw_text: str) -> str:
104
+ if not raw_text:
105
+ return ""
106
+
107
+ text = raw_text.strip()
108
+
109
+ if text.startswith("```"):
110
+ text = re.sub(r"^```[a-zA-Z0-9_-]*\n?", "", text)
111
+ text = re.sub(r"\n?```$", "", text)
112
+ text = text.strip()
113
+
114
+ prefixes_to_strip = [
115
+ r"^commit\s*message\s*:\s*",
116
+ r"^suggested\s*commit\s*(message)?\s*:\s*",
117
+ r"^here\s+is\s+(the|a)\s+suggested\s+commit\s+message\s*:\s*",
118
+ r"^here\s+is\s+the\s+commit\s+message\s*:\s*",
119
+ r"^here\s+is\s+your\s+commit\s+message\s*:\s*",
120
+ ]
121
+ for prefix in prefixes_to_strip:
122
+ text = re.sub(prefix, "", text, flags=re.IGNORECASE).strip()
123
+
124
+ quote_pairs = [
125
+ ('"', '"'),
126
+ ("'", "'"),
127
+ ("`", "`"),
128
+ ("“", "”"),
129
+ ("‘", "’"),
130
+ ]
131
+ for start_q, end_q in quote_pairs:
132
+ if text.startswith(start_q) and text.endswith(end_q) and len(text) >= 2:
133
+ text = text[len(start_q):-len(end_q)].strip()
134
+
135
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
136
+ if not lines:
137
+ return ""
138
+
139
+ if len(lines) > 1 and lines[0].lower().endswith(":") and len(lines[0].split()) < 8:
140
+ text = "\n\n".join(lines[1:])
141
+ else:
142
+ text = "\n\n".join(lines)
143
+
144
+ for start_q, end_q in quote_pairs:
145
+ if text.startswith(start_q) and text.endswith(end_q) and len(text) >= 2:
146
+ text = text[len(start_q):-len(end_q)].strip()
147
+
148
+ return text.strip()
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from comit.review.base import BaseAnalyzer
4
+ from comit.review.databases import DatabaseAnalyzer
5
+ from comit.review.engine import ReviewEngine, review_changes
6
+ from comit.review.generated_files import GeneratedFileAnalyzer
7
+ from comit.review.git_files import GitFileAnalyzer
8
+ from comit.review.large_files import DEFAULT_MAX_FILE_SIZE_BYTES, LargeFileAnalyzer
9
+ from comit.review.models import ReviewFinding, ReviewResult, ReviewSeverity
10
+ from comit.review.private_keys import PrivateKeyAnalyzer
11
+ from comit.review.secrets import SecretAnalyzer
12
+ from comit.review.sensitive_files import SensitiveFileAnalyzer
13
+
14
+ __all__ = [
15
+ "ReviewSeverity",
16
+ "ReviewFinding",
17
+ "ReviewResult",
18
+ "BaseAnalyzer",
19
+ "SensitiveFileAnalyzer",
20
+ "SecretAnalyzer",
21
+ "PrivateKeyAnalyzer",
22
+ "DatabaseAnalyzer",
23
+ "LargeFileAnalyzer",
24
+ "GeneratedFileAnalyzer",
25
+ "GitFileAnalyzer",
26
+ "DEFAULT_MAX_FILE_SIZE_BYTES",
27
+ "ReviewEngine",
28
+ "review_changes",
29
+ ]
comit/review/base.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List
5
+
6
+ from comit.commit.context import CommitContext
7
+ from comit.review.models import ReviewFinding
8
+
9
+
10
+ class BaseAnalyzer(ABC):
11
+ @abstractmethod
12
+ def analyze(self, context: CommitContext) -> List[ReviewFinding]:
13
+ pass
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from comit.commit.context import CommitContext
7
+ from comit.review.base import BaseAnalyzer
8
+ from comit.review.models import ReviewFinding, ReviewSeverity
9
+
10
+ DATABASE_EXTENSIONS = {
11
+ ".db",
12
+ ".sqlite",
13
+ ".sqlite3",
14
+ ".rdb",
15
+ ".mdb",
16
+ ".accdb",
17
+ }
18
+
19
+
20
+ class DatabaseAnalyzer(BaseAnalyzer):
21
+ def analyze(self, context: CommitContext) -> List[ReviewFinding]:
22
+ findings: List[ReviewFinding] = []
23
+
24
+ for f in context.changed_files:
25
+ if f.status == "D":
26
+ continue
27
+
28
+ suffix = Path(f.path).suffix.lower()
29
+ if suffix in DATABASE_EXTENSIONS:
30
+ findings.append(
31
+ ReviewFinding(
32
+ severity=ReviewSeverity.WARNING,
33
+ category="Database File",
34
+ message="Database file staged",
35
+ file_path=f.path,
36
+ details="Binary database files should typically not be tracked in version control.",
37
+ )
38
+ )
39
+
40
+ return findings
comit/review/engine.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional
4
+
5
+ from comit.commit.context import CommitContext
6
+ from comit.review.base import BaseAnalyzer
7
+ from comit.review.databases import DatabaseAnalyzer
8
+ from comit.review.generated_files import GeneratedFileAnalyzer
9
+ from comit.review.git_files import GitFileAnalyzer
10
+ from comit.review.large_files import LargeFileAnalyzer
11
+ from comit.review.models import ReviewFinding, ReviewResult
12
+ from comit.review.private_keys import PrivateKeyAnalyzer
13
+ from comit.review.secrets import SecretAnalyzer
14
+ from comit.review.sensitive_files import SensitiveFileAnalyzer
15
+
16
+
17
+ class ReviewEngine:
18
+ def __init__(self, analyzers: Optional[List[BaseAnalyzer]] = None):
19
+ if analyzers is not None:
20
+ self.analyzers = analyzers
21
+ else:
22
+ self.analyzers = [
23
+ SensitiveFileAnalyzer(),
24
+ SecretAnalyzer(),
25
+ PrivateKeyAnalyzer(),
26
+ DatabaseAnalyzer(),
27
+ LargeFileAnalyzer(),
28
+ GeneratedFileAnalyzer(),
29
+ GitFileAnalyzer(),
30
+ ]
31
+
32
+ def run(self, context: CommitContext) -> ReviewResult:
33
+ all_findings: List[ReviewFinding] = []
34
+
35
+ for analyzer in self.analyzers:
36
+ findings = analyzer.analyze(context)
37
+ all_findings.extend(findings)
38
+
39
+ deduped = self._deduplicate_findings(all_findings)
40
+ deduped.sort(key=lambda f: (-f.severity.rank, f.file_path, f.category))
41
+
42
+ added_count = sum(1 for f in context.changed_files if f.status == "A")
43
+ modified_count = sum(1 for f in context.changed_files if f.status == "M")
44
+ deleted_count = sum(1 for f in context.changed_files if f.status == "D")
45
+ renamed_count = sum(1 for f in context.changed_files if f.status == "R")
46
+
47
+ return ReviewResult(
48
+ findings=deduped,
49
+ files_checked=len(context.changed_files),
50
+ diff_stat=context.diff_stat,
51
+ added_files_count=added_count,
52
+ modified_files_count=modified_count,
53
+ deleted_files_count=deleted_count,
54
+ renamed_files_count=renamed_count,
55
+ )
56
+
57
+ def _deduplicate_findings(self, findings: List[ReviewFinding]) -> List[ReviewFinding]:
58
+ deduped: List[ReviewFinding] = []
59
+ seen = set()
60
+
61
+ for f in findings:
62
+ key = (f.file_path, f.category, f.message, f.line_number)
63
+ if key in seen:
64
+ continue
65
+
66
+ is_duplicate = False
67
+ for idx, existing in enumerate(deduped):
68
+ if existing.file_path == f.file_path:
69
+ if existing.category == "Private Key" and f.category == "Secret":
70
+ if existing.line_number is None or f.line_number is None or existing.line_number == f.line_number:
71
+ is_duplicate = True
72
+ break
73
+ elif f.category == "Private Key" and existing.category == "Secret":
74
+ if existing.line_number is None or f.line_number is None or existing.line_number == f.line_number:
75
+ deduped[idx] = f
76
+ is_duplicate = True
77
+ break
78
+ elif existing.category == f.category and existing.message == f.message and existing.details == f.details:
79
+ is_duplicate = True
80
+ break
81
+
82
+ if not is_duplicate:
83
+ seen.add(key)
84
+ deduped.append(f)
85
+
86
+ return deduped
87
+
88
+
89
+ def review_changes(context: CommitContext, engine: Optional[ReviewEngine] = None) -> ReviewResult:
90
+ if engine is None:
91
+ engine = ReviewEngine()
92
+ return engine.run(context)
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from comit.commit.context import CommitContext
7
+ from comit.review.base import BaseAnalyzer
8
+ from comit.review.models import ReviewFinding, ReviewSeverity
9
+
10
+ GENERATED_DIR_NAMES = {
11
+ "__pycache__",
12
+ "node_modules",
13
+ "dist",
14
+ "build",
15
+ "coverage",
16
+ "htmlcov",
17
+ ".pytest_cache",
18
+ ".mypy_cache",
19
+ ".ruff_cache",
20
+ ".tox",
21
+ }
22
+
23
+ GENERATED_EXTENSIONS = {
24
+ ".pyc",
25
+ ".pyo",
26
+ ".pyd",
27
+ }
28
+
29
+ GENERATED_FILE_NAMES = {
30
+ ".coverage",
31
+ }
32
+
33
+
34
+ class GeneratedFileAnalyzer(BaseAnalyzer):
35
+ def analyze(self, context: CommitContext) -> List[ReviewFinding]:
36
+ findings: List[ReviewFinding] = []
37
+
38
+ for f in context.changed_files:
39
+ if f.status == "D":
40
+ continue
41
+
42
+ path_obj = Path(f.path)
43
+ parts = [p.lower() for p in path_obj.parts]
44
+ name = path_obj.name.lower()
45
+ suffix = path_obj.suffix.lower()
46
+
47
+ is_generated = False
48
+ matched_reason = ""
49
+
50
+ for part in parts[:-1]:
51
+ if part in GENERATED_DIR_NAMES:
52
+ is_generated = True
53
+ matched_reason = f"Located inside '{part}' directory"
54
+ break
55
+
56
+ if not is_generated:
57
+ if name in GENERATED_FILE_NAMES:
58
+ is_generated = True
59
+ matched_reason = f"Artifact '{name}'"
60
+ elif suffix in GENERATED_EXTENSIONS:
61
+ is_generated = True
62
+ matched_reason = f"Compiled artifact ('{suffix}')"
63
+
64
+ if is_generated:
65
+ findings.append(
66
+ ReviewFinding(
67
+ severity=ReviewSeverity.INFO,
68
+ category="Generated Artifact",
69
+ message="Generated build or cache artifact staged",
70
+ file_path=f.path,
71
+ details=f"{matched_reason}. Consider adding it to .gitignore.",
72
+ )
73
+ )
74
+
75
+ return findings