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/cli.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from comit import __version__
|
|
9
|
+
from comit.ai import (
|
|
10
|
+
ComitAIError,
|
|
11
|
+
generate_commit_message,
|
|
12
|
+
)
|
|
13
|
+
from comit.config import (
|
|
14
|
+
get_config_summary,
|
|
15
|
+
get_provider,
|
|
16
|
+
get_model,
|
|
17
|
+
get_ollama_host,
|
|
18
|
+
set_user_config_value,
|
|
19
|
+
reset_user_config,
|
|
20
|
+
normalize_api_key,
|
|
21
|
+
SUPPORTED_PROVIDERS,
|
|
22
|
+
)
|
|
23
|
+
from comit.commit import CommitContext, validate_commit_message
|
|
24
|
+
from comit.git import (
|
|
25
|
+
GitError,
|
|
26
|
+
is_git_repository,
|
|
27
|
+
get_staged_diff,
|
|
28
|
+
get_recent_commits,
|
|
29
|
+
get_repository_name,
|
|
30
|
+
get_current_branch,
|
|
31
|
+
get_staged_changed_files,
|
|
32
|
+
get_diff_stat,
|
|
33
|
+
get_commit_context,
|
|
34
|
+
create_commit,
|
|
35
|
+
get_remotes,
|
|
36
|
+
get_default_remote,
|
|
37
|
+
push_commit,
|
|
38
|
+
)
|
|
39
|
+
from comit.review import review_changes
|
|
40
|
+
from comit.ui import (
|
|
41
|
+
console,
|
|
42
|
+
show_step_success,
|
|
43
|
+
show_not_git_repository,
|
|
44
|
+
show_no_staged_changes,
|
|
45
|
+
display_suggested_commit,
|
|
46
|
+
display_review_result,
|
|
47
|
+
display_validation_result,
|
|
48
|
+
prompt_action,
|
|
49
|
+
prompt_edit,
|
|
50
|
+
prompt_confirm_continue_commit,
|
|
51
|
+
show_commit_success,
|
|
52
|
+
show_auto_commit_success,
|
|
53
|
+
show_cancelled,
|
|
54
|
+
show_error,
|
|
55
|
+
show_settings_summary,
|
|
56
|
+
prompt_settings_menu,
|
|
57
|
+
prompt_provider_selection,
|
|
58
|
+
prompt_api_key,
|
|
59
|
+
prompt_model,
|
|
60
|
+
prompt_ollama_host,
|
|
61
|
+
prompt_confirm_reset,
|
|
62
|
+
prompt_confirm_push,
|
|
63
|
+
show_push_success,
|
|
64
|
+
show_push_skipped,
|
|
65
|
+
show_no_remote,
|
|
66
|
+
show_no_default_remote,
|
|
67
|
+
show_push_error,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
app = typer.Typer(
|
|
71
|
+
name="comit",
|
|
72
|
+
help="Comit: AI-powered Git commit assistant.",
|
|
73
|
+
add_completion=False,
|
|
74
|
+
no_args_is_help=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
settings_app = typer.Typer(
|
|
78
|
+
name="settings",
|
|
79
|
+
help="View and configure Comit settings.",
|
|
80
|
+
invoke_without_command=True,
|
|
81
|
+
no_args_is_help=False,
|
|
82
|
+
)
|
|
83
|
+
app.add_typer(settings_app, name="settings")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def version_callback(value: bool):
|
|
87
|
+
if value:
|
|
88
|
+
console.print(f"Comit version {__version__}")
|
|
89
|
+
raise typer.Exit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.callback()
|
|
93
|
+
def main_callback(
|
|
94
|
+
version: Optional[bool] = typer.Option(
|
|
95
|
+
None,
|
|
96
|
+
"--version",
|
|
97
|
+
"-v",
|
|
98
|
+
help="Show version and exit.",
|
|
99
|
+
callback=version_callback,
|
|
100
|
+
is_eager=True,
|
|
101
|
+
),
|
|
102
|
+
):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _perform_push(explicit_push: bool) -> None:
|
|
107
|
+
try:
|
|
108
|
+
remotes = get_remotes()
|
|
109
|
+
except GitError as e:
|
|
110
|
+
show_push_error(str(e))
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if not remotes:
|
|
114
|
+
show_no_remote(explicit_push=explicit_push)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
remote = get_default_remote()
|
|
118
|
+
if not remote:
|
|
119
|
+
show_no_default_remote()
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
branch = get_current_branch()
|
|
124
|
+
except GitError as e:
|
|
125
|
+
show_push_error(str(e))
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if not branch:
|
|
129
|
+
show_push_error("Could not determine current branch (detached HEAD).")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if not explicit_push:
|
|
133
|
+
try:
|
|
134
|
+
should_push = prompt_confirm_push()
|
|
135
|
+
except Exception:
|
|
136
|
+
should_push = False
|
|
137
|
+
if not should_push:
|
|
138
|
+
show_push_skipped()
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
success, output = push_commit(remote, branch)
|
|
143
|
+
if success:
|
|
144
|
+
show_push_success(remote, branch)
|
|
145
|
+
else:
|
|
146
|
+
show_push_error(output)
|
|
147
|
+
except GitError as e:
|
|
148
|
+
show_push_error(str(e))
|
|
149
|
+
except Exception as e:
|
|
150
|
+
show_push_error(f"Unexpected error during push: {e}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command(name="commit", help="Generate an AI-powered commit message for staged changes.")
|
|
154
|
+
def commit_command(
|
|
155
|
+
yes: bool = typer.Option(
|
|
156
|
+
False,
|
|
157
|
+
"-y",
|
|
158
|
+
"--yes",
|
|
159
|
+
help="Automatically accept the generated commit message without confirmation.",
|
|
160
|
+
),
|
|
161
|
+
push: bool = typer.Option(
|
|
162
|
+
False,
|
|
163
|
+
"--push",
|
|
164
|
+
help="Push the resulting commit to the remote after creation.",
|
|
165
|
+
),
|
|
166
|
+
):
|
|
167
|
+
try:
|
|
168
|
+
if not is_git_repository():
|
|
169
|
+
show_not_git_repository()
|
|
170
|
+
raise typer.Exit(code=1)
|
|
171
|
+
except GitError as e:
|
|
172
|
+
show_error(str(e))
|
|
173
|
+
raise typer.Exit(code=1)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
diff = get_staged_diff()
|
|
177
|
+
if not diff:
|
|
178
|
+
show_no_staged_changes()
|
|
179
|
+
raise typer.Exit(code=1)
|
|
180
|
+
except GitError as e:
|
|
181
|
+
show_error(str(e))
|
|
182
|
+
raise typer.Exit(code=1)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
recent_commits = get_recent_commits(count=15)
|
|
186
|
+
repo_name = get_repository_name()
|
|
187
|
+
branch = get_current_branch() or "HEAD"
|
|
188
|
+
changed_files = get_staged_changed_files()
|
|
189
|
+
diff_stat = get_diff_stat()
|
|
190
|
+
except GitError as e:
|
|
191
|
+
show_error(str(e))
|
|
192
|
+
raise typer.Exit(code=1)
|
|
193
|
+
|
|
194
|
+
context = CommitContext(
|
|
195
|
+
repository_name=repo_name,
|
|
196
|
+
current_branch=branch,
|
|
197
|
+
changed_files=changed_files,
|
|
198
|
+
recent_commits=recent_commits,
|
|
199
|
+
diff_stat=diff_stat,
|
|
200
|
+
staged_diff=diff,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
review_result = review_changes(context)
|
|
204
|
+
if review_result.has_findings:
|
|
205
|
+
display_review_result(review_result)
|
|
206
|
+
should_continue = prompt_confirm_continue_commit()
|
|
207
|
+
if not should_continue:
|
|
208
|
+
show_cancelled()
|
|
209
|
+
return
|
|
210
|
+
else:
|
|
211
|
+
console.print("No issues detected.\n")
|
|
212
|
+
|
|
213
|
+
current_message = ""
|
|
214
|
+
seen_messages: List[str] = []
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
current_message = generate_commit_message(context=context)
|
|
218
|
+
seen_messages.append(current_message)
|
|
219
|
+
except ComitAIError as e:
|
|
220
|
+
show_error(str(e))
|
|
221
|
+
raise typer.Exit(code=1)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
show_error(f"Unexpected error during generation: {e}")
|
|
224
|
+
raise typer.Exit(code=1)
|
|
225
|
+
|
|
226
|
+
if yes:
|
|
227
|
+
val_result = validate_commit_message(current_message, context=context)
|
|
228
|
+
if val_result.has_warnings or val_result.has_errors:
|
|
229
|
+
display_suggested_commit(current_message)
|
|
230
|
+
display_validation_result(val_result)
|
|
231
|
+
should_continue = prompt_confirm_continue_commit()
|
|
232
|
+
if not should_continue:
|
|
233
|
+
show_cancelled()
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
success, output = create_commit(current_message)
|
|
238
|
+
if success:
|
|
239
|
+
show_auto_commit_success(current_message)
|
|
240
|
+
if push:
|
|
241
|
+
_perform_push(explicit_push=True)
|
|
242
|
+
else:
|
|
243
|
+
show_error(f"Git commit failed:\n{output}")
|
|
244
|
+
raise typer.Exit(code=1)
|
|
245
|
+
except GitError as e:
|
|
246
|
+
show_error(f"Git commit error: {e}")
|
|
247
|
+
raise typer.Exit(code=1)
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
while True:
|
|
251
|
+
val_result = validate_commit_message(current_message, context=context)
|
|
252
|
+
display_suggested_commit(current_message)
|
|
253
|
+
if val_result.has_warnings or val_result.has_errors:
|
|
254
|
+
display_validation_result(val_result)
|
|
255
|
+
action = prompt_action()
|
|
256
|
+
|
|
257
|
+
if action in ("accept", "a"):
|
|
258
|
+
try:
|
|
259
|
+
success, output = create_commit(current_message)
|
|
260
|
+
if success:
|
|
261
|
+
show_commit_success(current_message)
|
|
262
|
+
_perform_push(explicit_push=push)
|
|
263
|
+
return
|
|
264
|
+
else:
|
|
265
|
+
show_error(f"Git commit failed:\n{output}")
|
|
266
|
+
raise typer.Exit(code=1)
|
|
267
|
+
except GitError as e:
|
|
268
|
+
show_error(f"Git commit error: {e}")
|
|
269
|
+
raise typer.Exit(code=1)
|
|
270
|
+
|
|
271
|
+
elif action in ("edit", "e"):
|
|
272
|
+
edited = prompt_edit(current_message)
|
|
273
|
+
if edited:
|
|
274
|
+
current_message = edited
|
|
275
|
+
|
|
276
|
+
elif action in ("regenerate", "r"):
|
|
277
|
+
try:
|
|
278
|
+
current_message = generate_commit_message(
|
|
279
|
+
context=context,
|
|
280
|
+
avoid_messages=seen_messages,
|
|
281
|
+
)
|
|
282
|
+
seen_messages.append(current_message)
|
|
283
|
+
except ComitAIError as e:
|
|
284
|
+
show_error(str(e))
|
|
285
|
+
except Exception as e:
|
|
286
|
+
show_error(f"Unexpected error during regeneration: {e}")
|
|
287
|
+
|
|
288
|
+
elif action in ("cancel", "c", "exit"):
|
|
289
|
+
show_cancelled()
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@app.command(name="review", help="Analyze staged changes for potential security and safety issues.")
|
|
294
|
+
def review_command():
|
|
295
|
+
try:
|
|
296
|
+
if not is_git_repository():
|
|
297
|
+
show_not_git_repository()
|
|
298
|
+
raise typer.Exit(code=1)
|
|
299
|
+
except GitError as e:
|
|
300
|
+
show_error(str(e))
|
|
301
|
+
raise typer.Exit(code=1)
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
context = get_commit_context()
|
|
305
|
+
if not context.changed_files and not context.staged_diff:
|
|
306
|
+
show_no_staged_changes()
|
|
307
|
+
raise typer.Exit(code=1)
|
|
308
|
+
except GitError as e:
|
|
309
|
+
show_error(str(e))
|
|
310
|
+
raise typer.Exit(code=1)
|
|
311
|
+
|
|
312
|
+
result = review_changes(context)
|
|
313
|
+
display_review_result(result)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _run_interactive_settings() -> None:
|
|
317
|
+
while True:
|
|
318
|
+
current_p = get_provider()
|
|
319
|
+
choice = prompt_settings_menu(provider=current_p)
|
|
320
|
+
if choice == "view":
|
|
321
|
+
show_settings_summary(get_config_summary())
|
|
322
|
+
elif choice == "set_provider":
|
|
323
|
+
new_provider = prompt_provider_selection(current_provider=current_p)
|
|
324
|
+
if new_provider in SUPPORTED_PROVIDERS:
|
|
325
|
+
set_user_config_value("provider", new_provider)
|
|
326
|
+
show_step_success(f"AI provider set to {new_provider}.")
|
|
327
|
+
elif choice == "set_key":
|
|
328
|
+
if current_p == "ollama":
|
|
329
|
+
current_host = get_ollama_host()
|
|
330
|
+
new_host = prompt_ollama_host(current_host)
|
|
331
|
+
if new_host:
|
|
332
|
+
set_user_config_value("ollama_host", new_host)
|
|
333
|
+
show_step_success(f"Ollama host set to {new_host}.")
|
|
334
|
+
else:
|
|
335
|
+
key = prompt_api_key(provider=current_p)
|
|
336
|
+
norm_key = normalize_api_key(key)
|
|
337
|
+
if norm_key:
|
|
338
|
+
set_user_config_value(f"{current_p}_api_key", norm_key)
|
|
339
|
+
if current_p == "groq":
|
|
340
|
+
set_user_config_value("groq_api_key", norm_key)
|
|
341
|
+
show_step_success(f"{current_p.capitalize()} API key saved.")
|
|
342
|
+
else:
|
|
343
|
+
show_error("API key cannot be empty.")
|
|
344
|
+
elif choice == "set_model":
|
|
345
|
+
current_model = get_model(provider=current_p)
|
|
346
|
+
new_model = prompt_model(current_model, provider=current_p)
|
|
347
|
+
if new_model:
|
|
348
|
+
set_user_config_value(f"{current_p}_model", new_model)
|
|
349
|
+
if current_p == "groq":
|
|
350
|
+
set_user_config_value("groq_model", new_model)
|
|
351
|
+
show_step_success(f"{current_p.capitalize()} model set to {new_model}.")
|
|
352
|
+
elif choice == "reset":
|
|
353
|
+
if prompt_confirm_reset():
|
|
354
|
+
reset_user_config()
|
|
355
|
+
show_step_success("User configuration reset.")
|
|
356
|
+
elif choice in ("exit", "cancel", "q"):
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@settings_app.callback(invoke_without_command=True)
|
|
361
|
+
def settings_callback(ctx: typer.Context):
|
|
362
|
+
if ctx.invoked_subcommand is None:
|
|
363
|
+
_run_interactive_settings()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@settings_app.command(name="show", help="Display current configuration.")
|
|
367
|
+
def settings_show():
|
|
368
|
+
show_settings_summary(get_config_summary())
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@settings_app.command(name="set-provider", help="Configure default AI provider.")
|
|
372
|
+
def settings_set_provider(
|
|
373
|
+
provider: str = typer.Argument(..., help="Provider name (groq, gemini, openai, ollama)"),
|
|
374
|
+
):
|
|
375
|
+
p = provider.strip().lower()
|
|
376
|
+
if p not in SUPPORTED_PROVIDERS:
|
|
377
|
+
show_error(f"Unsupported provider '{p}'. Supported providers: {', '.join(SUPPORTED_PROVIDERS)}")
|
|
378
|
+
raise typer.Exit(code=1)
|
|
379
|
+
set_user_config_value("provider", p)
|
|
380
|
+
show_step_success(f"AI provider set to {p}.")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@settings_app.command(name="set-model", help="Configure default model for a provider.")
|
|
384
|
+
def settings_set_model(
|
|
385
|
+
model: str = typer.Argument(..., help="Model name"),
|
|
386
|
+
provider: Optional[str] = typer.Option(None, "--provider", "-p", help="Target provider (defaults to active provider)"),
|
|
387
|
+
):
|
|
388
|
+
if not model.strip():
|
|
389
|
+
show_error("Model name cannot be empty.")
|
|
390
|
+
raise typer.Exit(code=1)
|
|
391
|
+
p = (provider or get_provider()).strip().lower()
|
|
392
|
+
if p not in SUPPORTED_PROVIDERS:
|
|
393
|
+
show_error(f"Unsupported provider '{p}'. Supported providers: {', '.join(SUPPORTED_PROVIDERS)}")
|
|
394
|
+
raise typer.Exit(code=1)
|
|
395
|
+
set_user_config_value(f"{p}_model", model.strip())
|
|
396
|
+
if p == "groq":
|
|
397
|
+
set_user_config_value("groq_model", model.strip())
|
|
398
|
+
show_step_success(f"{p.capitalize()} model set to {model.strip()}.")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@settings_app.command(name="set-key", help="Configure API key for a provider.")
|
|
402
|
+
def settings_set_key(
|
|
403
|
+
key: Optional[str] = typer.Option(None, "--key", "-k", help="API key"),
|
|
404
|
+
provider: Optional[str] = typer.Option(None, "--provider", "-p", help="Target provider (defaults to active provider)"),
|
|
405
|
+
):
|
|
406
|
+
p = (provider or get_provider()).strip().lower()
|
|
407
|
+
if p == "ollama":
|
|
408
|
+
show_error("Ollama runs locally and does not use an API key. Use 'set-host' to configure the host.")
|
|
409
|
+
raise typer.Exit(code=1)
|
|
410
|
+
if p not in SUPPORTED_PROVIDERS:
|
|
411
|
+
show_error(f"Unsupported provider '{p}'. Supported providers: {', '.join(SUPPORTED_PROVIDERS)}")
|
|
412
|
+
raise typer.Exit(code=1)
|
|
413
|
+
if not key:
|
|
414
|
+
key = prompt_api_key(provider=p)
|
|
415
|
+
norm_key = normalize_api_key(key)
|
|
416
|
+
if not norm_key:
|
|
417
|
+
show_error("API key cannot be empty.")
|
|
418
|
+
raise typer.Exit(code=1)
|
|
419
|
+
set_user_config_value(f"{p}_api_key", norm_key)
|
|
420
|
+
if p == "groq":
|
|
421
|
+
set_user_config_value("groq_api_key", norm_key)
|
|
422
|
+
show_step_success(f"{p.capitalize()} API key saved.")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@settings_app.command(name="set-host", help="Configure host URL for Ollama.")
|
|
426
|
+
def settings_set_host(
|
|
427
|
+
host: str = typer.Argument(..., help="Ollama host URL, e.g. http://localhost:11434"),
|
|
428
|
+
):
|
|
429
|
+
if not host.strip():
|
|
430
|
+
show_error("Host cannot be empty.")
|
|
431
|
+
raise typer.Exit(code=1)
|
|
432
|
+
set_user_config_value("ollama_host", host.strip())
|
|
433
|
+
show_step_success(f"Ollama host set to {host.strip()}.")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@settings_app.command(name="reset", help="Reset user configuration.")
|
|
437
|
+
def settings_reset(
|
|
438
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Confirm reset without prompt"),
|
|
439
|
+
):
|
|
440
|
+
if yes or prompt_confirm_reset():
|
|
441
|
+
reset_user_config()
|
|
442
|
+
show_step_success("User configuration reset.")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
if __name__ == "__main__":
|
|
446
|
+
app()
|
comit/commit/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from comit.commit.context import ChangedFile, DiffStat, CommitContext
|
|
4
|
+
from comit.commit.validator import (
|
|
5
|
+
CommitValidator,
|
|
6
|
+
ValidationSeverity,
|
|
7
|
+
ValidationFinding,
|
|
8
|
+
ValidationResult,
|
|
9
|
+
validate_commit_message,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ChangedFile",
|
|
14
|
+
"DiffStat",
|
|
15
|
+
"CommitContext",
|
|
16
|
+
"CommitValidator",
|
|
17
|
+
"ValidationSeverity",
|
|
18
|
+
"ValidationFinding",
|
|
19
|
+
"ValidationResult",
|
|
20
|
+
"validate_commit_message",
|
|
21
|
+
]
|
comit/commit/context.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ChangedFile:
|
|
9
|
+
path: str
|
|
10
|
+
status: str
|
|
11
|
+
old_path: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def display_status(self) -> str:
|
|
15
|
+
status_map = {
|
|
16
|
+
"A": "Added",
|
|
17
|
+
"M": "Modified",
|
|
18
|
+
"D": "Deleted",
|
|
19
|
+
"R": "Renamed",
|
|
20
|
+
"C": "Copied",
|
|
21
|
+
"T": "Type changed",
|
|
22
|
+
"U": "Unmerged",
|
|
23
|
+
}
|
|
24
|
+
return status_map.get(self.status, self.status)
|
|
25
|
+
|
|
26
|
+
def format_line(self) -> str:
|
|
27
|
+
if self.old_path:
|
|
28
|
+
return f" {self.status} {self.old_path} -> {self.path}"
|
|
29
|
+
return f" {self.status} {self.path}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class DiffStat:
|
|
34
|
+
files_changed: int = 0
|
|
35
|
+
insertions: int = 0
|
|
36
|
+
deletions: int = 0
|
|
37
|
+
summary_text: str = ""
|
|
38
|
+
|
|
39
|
+
def format_summary(self) -> str:
|
|
40
|
+
if self.summary_text:
|
|
41
|
+
return self.summary_text
|
|
42
|
+
parts = []
|
|
43
|
+
if self.files_changed:
|
|
44
|
+
parts.append(f"{self.files_changed} file{'s' if self.files_changed != 1 else ''} changed")
|
|
45
|
+
if self.insertions:
|
|
46
|
+
parts.append(f"{self.insertions} insertion{'(+)' if self.insertions == 1 else 's(+)'}")
|
|
47
|
+
if self.deletions:
|
|
48
|
+
parts.append(f"{self.deletions} deletion{'(-)' if self.deletions == 1 else 's(-)'}")
|
|
49
|
+
return ", ".join(parts) if parts else "0 files changed"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class CommitContext:
|
|
54
|
+
repository_name: str
|
|
55
|
+
current_branch: str
|
|
56
|
+
changed_files: List[ChangedFile] = field(default_factory=list)
|
|
57
|
+
recent_commits: List[str] = field(default_factory=list)
|
|
58
|
+
diff_stat: DiffStat = field(default_factory=DiffStat)
|
|
59
|
+
staged_diff: str = ""
|
|
60
|
+
|