gitwit 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.
gitwit/cli.py ADDED
@@ -0,0 +1,520 @@
1
+ """CLI commands for GitWit."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from . import __version__
10
+ from .ai import AIError, AuthenticationError, generate_sync
11
+ from .config import (
12
+ get_api_key,
13
+ get_config_value,
14
+ get_model,
15
+ get_provider,
16
+ is_configured,
17
+ load_config,
18
+ set_config_value,
19
+ DEFAULT_MODELS,
20
+ )
21
+ from .git import (
22
+ GitError,
23
+ NoStagedChangesError,
24
+ NotAGitRepoError,
25
+ commit,
26
+ get_branch_diff,
27
+ get_current_branch,
28
+ get_default_branch,
29
+ get_staged_diff,
30
+ has_staged_changes,
31
+ is_git_repo,
32
+ parse_diff_stats,
33
+ stage_all_changes,
34
+ )
35
+ from .prompts import get_commit_prompt, get_pr_prompt
36
+
37
+
38
+ def style_error(message: str) -> str:
39
+ """Style an error message."""
40
+ return click.style(f"Error: {message}", fg="red")
41
+
42
+
43
+ def style_success(message: str) -> str:
44
+ """Style a success message."""
45
+ return click.style(message, fg="green")
46
+
47
+
48
+ def style_info(message: str) -> str:
49
+ """Style an info message."""
50
+ return click.style(message, fg="cyan")
51
+
52
+
53
+ def style_warning(message: str) -> str:
54
+ """Style a warning message."""
55
+ return click.style(message, fg="yellow")
56
+
57
+
58
+ def style_commit(message: str) -> str:
59
+ """Style a commit message."""
60
+ return click.style(message, fg="bright_white", bold=True)
61
+
62
+
63
+ @click.group(invoke_without_command=True)
64
+ @click.option("--version", "-v", is_flag=True, help="Show version and exit.")
65
+ @click.pass_context
66
+ def main(ctx: click.Context, version: bool) -> None:
67
+ """GitWit - AI-powered git commit messages using free APIs."""
68
+ if version:
69
+ click.echo(f"gitwit {__version__}")
70
+ return
71
+
72
+ if ctx.invoked_subcommand is None:
73
+ click.echo(ctx.get_help())
74
+
75
+
76
+ @main.command(name="commit")
77
+ @click.option("--all", "-a", "stage_all", is_flag=True, help="Stage all changes before committing.")
78
+ @click.option("--yes", "-y", is_flag=True, help="Accept the generated message without confirmation.")
79
+ @click.option("--dry-run", "-n", is_flag=True, help="Show the message without committing.")
80
+ def commit_cmd(stage_all: bool, yes: bool, dry_run: bool) -> None:
81
+ """Generate a commit message from staged changes."""
82
+ # Check if we're in a git repo
83
+ if not is_git_repo():
84
+ click.echo(style_error("Not a git repository. Run 'git init' first."))
85
+ sys.exit(1)
86
+
87
+ # Check configuration
88
+ if not is_configured():
89
+ click.echo(style_warning("GitWit is not configured yet."))
90
+ click.echo()
91
+ if click.confirm("Would you like to configure it now?"):
92
+ ctx = click.Context(config)
93
+ ctx.invoke(interactive_setup)
94
+ else:
95
+ click.echo("\nRun 'gitwit config set provider groq' to get started.")
96
+ sys.exit(1)
97
+
98
+ # Stage all changes if requested
99
+ if stage_all:
100
+ click.echo(style_info("Staging all changes..."))
101
+ stage_all_changes()
102
+
103
+ # Check for staged changes
104
+ if not has_staged_changes():
105
+ click.echo(style_error(
106
+ "No staged changes. Stage changes with 'git add' first, "
107
+ "or use 'gitwit commit --all' to stage all changes."
108
+ ))
109
+ sys.exit(1)
110
+
111
+ # Get the diff
112
+ try:
113
+ diff = get_staged_diff()
114
+ except NoStagedChangesError as e:
115
+ click.echo(style_error(str(e)))
116
+ sys.exit(1)
117
+ except GitError as e:
118
+ click.echo(style_error(str(e)))
119
+ sys.exit(1)
120
+
121
+ # Show diff stats
122
+ stats = parse_diff_stats(diff)
123
+ click.echo(style_info(
124
+ f"Analyzing {len(stats.files_changed)} file(s): "
125
+ f"+{stats.additions} -{stats.deletions}"
126
+ ))
127
+
128
+ # Generate commit message
129
+ provider = get_provider()
130
+ model = get_model()
131
+ click.echo(style_info(f"Generating commit message using {provider} ({model})..."))
132
+ click.echo()
133
+
134
+ try:
135
+ system_prompt, user_prompt = get_commit_prompt(diff)
136
+ message = generate_sync(system_prompt, user_prompt)
137
+
138
+ # Clean up the message (remove quotes if present)
139
+ message = message.strip().strip('"').strip("'")
140
+
141
+ # Remove any trailing punctuation that shouldn't be there
142
+ if message.endswith('.'):
143
+ message = message[:-1]
144
+
145
+ except AuthenticationError as e:
146
+ click.echo(style_error(str(e)))
147
+ sys.exit(1)
148
+ except AIError as e:
149
+ click.echo(style_error(str(e)))
150
+ sys.exit(1)
151
+
152
+ # Display the generated message
153
+ click.echo("Generated commit message:")
154
+ click.echo()
155
+ click.echo(f" {style_commit(message)}")
156
+ click.echo()
157
+
158
+ if dry_run:
159
+ click.echo(style_info("Dry run - no commit created."))
160
+ return
161
+
162
+ if yes:
163
+ # Auto-accept
164
+ action = "y"
165
+ else:
166
+ # Interactive prompt
167
+ click.echo("Options: [y]es, [e]dit, [r]egenerate, [n]o/cancel")
168
+ action = click.prompt(
169
+ "Accept this message?",
170
+ type=click.Choice(["y", "e", "r", "n"], case_sensitive=False),
171
+ default="y",
172
+ show_choices=False,
173
+ )
174
+
175
+ while True:
176
+ if action == "y":
177
+ # Accept and commit
178
+ try:
179
+ output = commit(message)
180
+ click.echo()
181
+ click.echo(style_success("Committed successfully!"))
182
+ # Show abbreviated output
183
+ for line in output.strip().split('\n')[:3]:
184
+ click.echo(f" {line}")
185
+ except GitError as e:
186
+ click.echo(style_error(str(e)))
187
+ sys.exit(1)
188
+ break
189
+
190
+ elif action == "e":
191
+ # Edit the message
192
+ edited = click.edit(message)
193
+ if edited:
194
+ message = edited.strip()
195
+ click.echo()
196
+ click.echo("Edited message:")
197
+ click.echo(f" {style_commit(message)}")
198
+ click.echo()
199
+ action = click.prompt(
200
+ "Accept this message?",
201
+ type=click.Choice(["y", "e", "r", "n"], case_sensitive=False),
202
+ default="y",
203
+ show_choices=False,
204
+ )
205
+ else:
206
+ click.echo(style_warning("Edit cancelled."))
207
+ action = click.prompt(
208
+ "Action?",
209
+ type=click.Choice(["y", "e", "r", "n"], case_sensitive=False),
210
+ default="y",
211
+ show_choices=False,
212
+ )
213
+
214
+ elif action == "r":
215
+ # Regenerate
216
+ click.echo()
217
+ click.echo(style_info("Regenerating..."))
218
+ try:
219
+ message = generate_sync(system_prompt, user_prompt)
220
+ message = message.strip().strip('"').strip("'")
221
+ if message.endswith('.'):
222
+ message = message[:-1]
223
+ except AIError as e:
224
+ click.echo(style_error(str(e)))
225
+ sys.exit(1)
226
+
227
+ click.echo()
228
+ click.echo("Generated commit message:")
229
+ click.echo(f" {style_commit(message)}")
230
+ click.echo()
231
+ action = click.prompt(
232
+ "Accept this message?",
233
+ type=click.Choice(["y", "e", "r", "n"], case_sensitive=False),
234
+ default="y",
235
+ show_choices=False,
236
+ )
237
+
238
+ elif action == "n":
239
+ click.echo(style_info("Commit cancelled."))
240
+ break
241
+
242
+
243
+ @main.command(name="pr")
244
+ @click.option("--base", "-b", default=None, help="Base branch to compare against (default: main or master).")
245
+ @click.option("--dry-run", "-n", is_flag=True, help="Show the PR description without copying.")
246
+ def pr_cmd(base: str | None, dry_run: bool) -> None:
247
+ """Generate a PR title and description from branch diff."""
248
+ # Check if we're in a git repo
249
+ if not is_git_repo():
250
+ click.echo(style_error("Not a git repository."))
251
+ sys.exit(1)
252
+
253
+ # Check configuration
254
+ if not is_configured():
255
+ click.echo(style_warning("GitWit is not configured yet."))
256
+ click.echo()
257
+ if click.confirm("Would you like to configure it now?"):
258
+ ctx = click.Context(config)
259
+ ctx.invoke(interactive_setup)
260
+ else:
261
+ click.echo("\nRun 'gitwit config set provider groq' to get started.")
262
+ sys.exit(1)
263
+
264
+ # Get current branch
265
+ current_branch = get_current_branch()
266
+ if not current_branch:
267
+ click.echo(style_error("Could not determine current branch."))
268
+ sys.exit(1)
269
+
270
+ # Detect base branch if not specified
271
+ if base is None:
272
+ try:
273
+ base = get_default_branch()
274
+ except GitError:
275
+ click.echo(style_error("Could not detect default branch. Use --base to specify."))
276
+ sys.exit(1)
277
+
278
+ # Check if we're on the base branch
279
+ if current_branch == base:
280
+ click.echo(style_error(f"Already on {base} branch. Switch to a feature branch first."))
281
+ sys.exit(1)
282
+
283
+ click.echo(style_info(f"Comparing {current_branch} → {base}"))
284
+
285
+ # Get the diff
286
+ try:
287
+ diff = get_branch_diff(base)
288
+ except GitError as e:
289
+ click.echo(style_error(str(e)))
290
+ sys.exit(1)
291
+
292
+ if not diff.strip():
293
+ click.echo(style_error(f"No changes between {current_branch} and {base}."))
294
+ sys.exit(1)
295
+
296
+ # Show diff stats
297
+ stats = parse_diff_stats(diff)
298
+ click.echo(style_info(
299
+ f"Analyzing {len(stats.files_changed)} file(s): "
300
+ f"+{stats.additions} -{stats.deletions}"
301
+ ))
302
+
303
+ # Generate PR description
304
+ provider = get_provider()
305
+ model = get_model()
306
+ click.echo(style_info(f"Generating PR description using {provider} ({model})..."))
307
+ click.echo()
308
+
309
+ try:
310
+ system_prompt, user_prompt = get_pr_prompt(diff, current_branch)
311
+ description = generate_sync(system_prompt, user_prompt)
312
+ description = description.strip()
313
+
314
+ except AuthenticationError as e:
315
+ click.echo(style_error(str(e)))
316
+ sys.exit(1)
317
+ except AIError as e:
318
+ click.echo(style_error(str(e)))
319
+ sys.exit(1)
320
+
321
+ # Display the generated description
322
+ click.echo(style_commit("=" * 60))
323
+ click.echo()
324
+ click.echo(description)
325
+ click.echo()
326
+ click.echo(style_commit("=" * 60))
327
+ click.echo()
328
+
329
+ if dry_run:
330
+ click.echo(style_info("Dry run - PR description displayed above."))
331
+ return
332
+
333
+ # Offer to copy to clipboard
334
+ click.echo("Options: [c]opy to clipboard, [r]egenerate, [q]uit")
335
+ action = click.prompt(
336
+ "Action?",
337
+ type=click.Choice(["c", "r", "q"], case_sensitive=False),
338
+ default="c",
339
+ show_choices=False,
340
+ )
341
+
342
+ while True:
343
+ if action == "c":
344
+ # Try to copy to clipboard
345
+ try:
346
+ import subprocess
347
+ process = subprocess.Popen(
348
+ ["pbcopy"] if sys.platform == "darwin" else ["xclip", "-selection", "clipboard"],
349
+ stdin=subprocess.PIPE
350
+ )
351
+ process.communicate(description.encode())
352
+ click.echo(style_success("Copied to clipboard!"))
353
+ except Exception:
354
+ click.echo(style_warning("Could not copy to clipboard. Copy manually from above."))
355
+ break
356
+
357
+ elif action == "r":
358
+ click.echo()
359
+ click.echo(style_info("Regenerating..."))
360
+ try:
361
+ description = generate_sync(system_prompt, user_prompt)
362
+ description = description.strip()
363
+ except AIError as e:
364
+ click.echo(style_error(str(e)))
365
+ sys.exit(1)
366
+
367
+ click.echo()
368
+ click.echo(style_commit("=" * 60))
369
+ click.echo()
370
+ click.echo(description)
371
+ click.echo()
372
+ click.echo(style_commit("=" * 60))
373
+ click.echo()
374
+ action = click.prompt(
375
+ "Action?",
376
+ type=click.Choice(["c", "r", "q"], case_sensitive=False),
377
+ default="c",
378
+ show_choices=False,
379
+ )
380
+
381
+ elif action == "q":
382
+ click.echo(style_info("Done."))
383
+ break
384
+
385
+
386
+ @main.group()
387
+ def config() -> None:
388
+ """Manage GitWit configuration."""
389
+ pass
390
+
391
+
392
+ @config.command("set")
393
+ @click.argument("key")
394
+ @click.argument("value")
395
+ def config_set(key: str, value: str) -> None:
396
+ """Set a configuration value."""
397
+ valid_keys = ["provider", "api-key", "api_key", "model"]
398
+
399
+ if key not in valid_keys:
400
+ click.echo(style_error(f"Unknown key: {key}"))
401
+ click.echo(f"Valid keys: {', '.join(valid_keys)}")
402
+ sys.exit(1)
403
+
404
+ # Validate provider
405
+ if key == "provider":
406
+ valid_providers = ["groq", "gemini", "ollama"]
407
+ if value.lower() not in valid_providers:
408
+ click.echo(style_error(f"Unknown provider: {value}"))
409
+ click.echo(f"Valid providers: {', '.join(valid_providers)}")
410
+ sys.exit(1)
411
+ value = value.lower()
412
+
413
+ set_config_value(key, value)
414
+ click.echo(style_success(f"Set {key} = {value if key != 'api-key' and key != 'api_key' else '***'}"))
415
+
416
+ # Show next steps if provider was set
417
+ if key == "provider" and value != "ollama":
418
+ api_key = get_api_key()
419
+ if not api_key:
420
+ click.echo()
421
+ click.echo(style_info("Next step: Set your API key:"))
422
+ if value == "groq":
423
+ click.echo(" gitwit config set api-key YOUR_GROQ_KEY")
424
+ click.echo(" Get a free key at: https://console.groq.com")
425
+ elif value == "gemini":
426
+ click.echo(" gitwit config set api-key YOUR_GEMINI_KEY")
427
+ click.echo(" Get a free key at: https://aistudio.google.com")
428
+
429
+
430
+ @config.command("show")
431
+ def config_show() -> None:
432
+ """Show current configuration."""
433
+ cfg = load_config()
434
+
435
+ click.echo()
436
+ click.echo(style_info("GitWit Configuration"))
437
+ click.echo(style_info("=" * 40))
438
+
439
+ provider = cfg.get("provider", "groq")
440
+ click.echo(f"Provider: {provider}")
441
+
442
+ model = cfg.get("model") or DEFAULT_MODELS.get(provider, "")
443
+ click.echo(f"Model: {model}")
444
+
445
+ api_key = cfg.get("api_key", "")
446
+ if api_key:
447
+ masked = api_key[:4] + "..." + api_key[-4:] if len(api_key) > 8 else "***"
448
+ click.echo(f"API Key: {masked}")
449
+ else:
450
+ click.echo(f"API Key: {style_warning('not set')}")
451
+
452
+ click.echo()
453
+
454
+ if not is_configured():
455
+ click.echo(style_warning("Configuration incomplete. Run:"))
456
+ if provider == "ollama":
457
+ click.echo(" Make sure Ollama is running: https://ollama.ai")
458
+ else:
459
+ click.echo(f" gitwit config set api-key YOUR_{provider.upper()}_KEY")
460
+
461
+
462
+ def interactive_setup() -> None:
463
+ """Run interactive configuration setup."""
464
+ click.echo()
465
+ click.echo(style_info("GitWit Setup"))
466
+ click.echo(style_info("=" * 40))
467
+ click.echo()
468
+
469
+ # Choose provider
470
+ click.echo("Choose your AI provider:")
471
+ click.echo(" 1. Groq (recommended) - 30 req/min free, fastest inference")
472
+ click.echo(" 2. Gemini - 1,500 req/day free")
473
+ click.echo(" 3. Ollama - Unlimited, runs locally")
474
+ click.echo()
475
+
476
+ choice = click.prompt(
477
+ "Provider",
478
+ type=click.Choice(["1", "2", "3"]),
479
+ default="1",
480
+ )
481
+
482
+ provider_map = {"1": "groq", "2": "gemini", "3": "ollama"}
483
+ provider = provider_map[choice]
484
+ set_config_value("provider", provider)
485
+
486
+ # Get API key for cloud providers
487
+ if provider != "ollama":
488
+ click.echo()
489
+ if provider == "groq":
490
+ click.echo("Get your free Groq API key at: https://console.groq.com")
491
+ else:
492
+ click.echo("Get your free Gemini API key at: https://aistudio.google.com")
493
+
494
+ click.echo()
495
+ api_key = click.prompt("API Key", hide_input=True)
496
+ set_config_value("api_key", api_key)
497
+ else:
498
+ click.echo()
499
+ click.echo(style_info("Make sure Ollama is running: https://ollama.ai"))
500
+ click.echo(f"Default model: {DEFAULT_MODELS['ollama']}")
501
+ if click.confirm("Use a different model?", default=False):
502
+ model = click.prompt("Model name")
503
+ set_config_value("model", model)
504
+
505
+ click.echo()
506
+ click.echo(style_success("Configuration complete!"))
507
+ click.echo()
508
+ click.echo("Try it out:")
509
+ click.echo(" git add .")
510
+ click.echo(" gitwit commit")
511
+
512
+
513
+ @config.command("init")
514
+ def config_init() -> None:
515
+ """Run interactive configuration setup."""
516
+ interactive_setup()
517
+
518
+
519
+ if __name__ == "__main__":
520
+ main()
gitwit/config.py ADDED
@@ -0,0 +1,169 @@
1
+ """Configuration management for GitWit."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ if sys.version_info >= (3, 11):
9
+ import tomllib
10
+ else:
11
+ import tomli as tomllib
12
+
13
+ import tomli_w
14
+
15
+
16
+ # Default configuration
17
+ DEFAULT_CONFIG = {
18
+ "provider": "groq",
19
+ "model": "", # Empty means use provider default
20
+ "api_key": "",
21
+ }
22
+
23
+ # Default models per provider
24
+ DEFAULT_MODELS = {
25
+ "groq": "llama-3.3-70b-versatile",
26
+ "gemini": "gemini-1.5-flash",
27
+ "ollama": "llama3.2",
28
+ }
29
+
30
+
31
+ def get_config_dir() -> Path:
32
+ """Get the GitWit configuration directory."""
33
+ return Path.home() / ".gitwit"
34
+
35
+
36
+ def get_config_path() -> Path:
37
+ """Get the path to the config file."""
38
+ return get_config_dir() / "config.toml"
39
+
40
+
41
+ def ensure_config_dir() -> None:
42
+ """Ensure the config directory exists."""
43
+ config_dir = get_config_dir()
44
+ config_dir.mkdir(parents=True, exist_ok=True)
45
+
46
+
47
+ def load_config() -> dict[str, Any]:
48
+ """
49
+ Load configuration from file.
50
+
51
+ Returns:
52
+ Configuration dictionary with defaults applied.
53
+ """
54
+ config_path = get_config_path()
55
+
56
+ if not config_path.exists():
57
+ return DEFAULT_CONFIG.copy()
58
+
59
+ try:
60
+ with open(config_path, "rb") as f:
61
+ config = tomllib.load(f)
62
+ except Exception:
63
+ return DEFAULT_CONFIG.copy()
64
+
65
+ # Merge with defaults
66
+ result = DEFAULT_CONFIG.copy()
67
+ result.update(config)
68
+ return result
69
+
70
+
71
+ def save_config(config: dict[str, Any]) -> None:
72
+ """
73
+ Save configuration to file.
74
+
75
+ Args:
76
+ config: Configuration dictionary to save.
77
+ """
78
+ ensure_config_dir()
79
+ config_path = get_config_path()
80
+
81
+ with open(config_path, "wb") as f:
82
+ tomli_w.dump(config, f)
83
+
84
+
85
+ def get_config_value(key: str) -> Any:
86
+ """
87
+ Get a specific configuration value.
88
+
89
+ Args:
90
+ key: Configuration key (supports dot notation for nested keys).
91
+
92
+ Returns:
93
+ The configuration value or None if not found.
94
+ """
95
+ config = load_config()
96
+
97
+ # Handle dot notation for nested keys
98
+ keys = key.replace("-", "_").split(".")
99
+ value = config
100
+
101
+ for k in keys:
102
+ if isinstance(value, dict):
103
+ value = value.get(k)
104
+ else:
105
+ return None
106
+
107
+ return value
108
+
109
+
110
+ def set_config_value(key: str, value: str) -> None:
111
+ """
112
+ Set a specific configuration value.
113
+
114
+ Args:
115
+ key: Configuration key (supports dot notation for nested keys).
116
+ value: Value to set.
117
+ """
118
+ config = load_config()
119
+
120
+ # Normalize key (api-key -> api_key)
121
+ key = key.replace("-", "_")
122
+
123
+ # Handle dot notation for nested keys
124
+ keys = key.split(".")
125
+
126
+ if len(keys) == 1:
127
+ config[keys[0]] = value
128
+ else:
129
+ # Navigate to parent and set value
130
+ current = config
131
+ for k in keys[:-1]:
132
+ if k not in current:
133
+ current[k] = {}
134
+ current = current[k]
135
+ current[keys[-1]] = value
136
+
137
+ save_config(config)
138
+
139
+
140
+ def get_api_key() -> str | None:
141
+ """Get the API key for the current provider."""
142
+ return get_config_value("api_key") or os.environ.get("GITWIT_API_KEY")
143
+
144
+
145
+ def get_provider() -> str:
146
+ """Get the current AI provider."""
147
+ return get_config_value("provider") or "groq"
148
+
149
+
150
+ def get_model() -> str:
151
+ """Get the current model, with provider default fallback."""
152
+ model = get_config_value("model")
153
+ if model:
154
+ return model
155
+
156
+ provider = get_provider()
157
+ return DEFAULT_MODELS.get(provider, "")
158
+
159
+
160
+ def is_configured() -> bool:
161
+ """Check if GitWit has been configured with necessary credentials."""
162
+ provider = get_provider()
163
+
164
+ # Ollama doesn't need an API key
165
+ if provider == "ollama":
166
+ return True
167
+
168
+ api_key = get_api_key()
169
+ return bool(api_key)