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/__init__.py +1 -0
- comit/ai/__init__.py +39 -0
- comit/ai/base.py +42 -0
- comit/ai/factory.py +59 -0
- comit/ai/gemini_provider.py +125 -0
- comit/ai/groq_provider.py +121 -0
- comit/ai/ollama_provider.py +107 -0
- comit/ai/openai_provider.py +107 -0
- comit/cli.py +446 -0
- comit/commit/__init__.py +21 -0
- comit/commit/context.py +60 -0
- comit/commit/validator.py +270 -0
- comit/config.py +279 -0
- comit/git.py +281 -0
- comit/prompts.py +148 -0
- comit/review/__init__.py +29 -0
- comit/review/base.py +13 -0
- comit/review/databases.py +40 -0
- comit/review/engine.py +92 -0
- comit/review/generated_files.py +75 -0
- comit/review/git_files.py +54 -0
- comit/review/large_files.py +38 -0
- comit/review/models.py +59 -0
- comit/review/private_keys.py +69 -0
- comit/review/secrets.py +230 -0
- comit/review/sensitive_files.py +79 -0
- comit/ui.py +412 -0
- comit_cli-1.0.0.dist-info/METADATA +524 -0
- comit_cli-1.0.0.dist-info/RECORD +32 -0
- comit_cli-1.0.0.dist-info/WHEEL +5 -0
- comit_cli-1.0.0.dist-info/entry_points.txt +3 -0
- comit_cli-1.0.0.dist-info/top_level.txt +1 -0
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()
|
comit/review/__init__.py
ADDED
|
@@ -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
|