gitauto-cli 1.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.
gitauto.py ADDED
@@ -0,0 +1,708 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ GitAuto — AI-powered git workflow automation.
4
+
5
+ Usage:
6
+ gitauto Run full workflow (add → commit → push)
7
+ gitauto setup Configure AI provider and API key
8
+ gitauto --no-push Skip push step
9
+ gitauto --no-ai Skip AI commit message generation
10
+ gitauto --force-push Force push to remote (destructive)
11
+ gitauto --branch <n> Switch to or create branch before committing
12
+ gitauto -v / --version Print version
13
+ """
14
+
15
+ import importlib
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import List, NamedTuple, Optional
22
+
23
+ __version__ = "1.1.0"
24
+
25
+ AI_VENV = Path.home() / ".gitauto" / "ai_venv"
26
+ CONFIG_DIR = Path.home() / ".gitauto"
27
+ CONFIG_FILE = CONFIG_DIR / "config.json"
28
+
29
+ SUPPORTED_PROVIDERS = ("openai", "anthropic", "gemini")
30
+
31
+
32
+ class CommandResult(NamedTuple):
33
+ ok: bool
34
+ stdout: str
35
+ stderr: str
36
+
37
+
38
+ class Colors:
39
+ HEADER = "\033[95m"
40
+ CYAN = "\033[96m"
41
+ GREEN = "\033[92m"
42
+ YELLOW = "\033[93m"
43
+ RED = "\033[91m"
44
+ END = "\033[0m"
45
+ BOLD = "\033[1m"
46
+
47
+
48
+ def _venv_site_packages(venv: Path) -> Optional[Path]:
49
+ if os.name == "nt":
50
+ candidate = venv / "Lib" / "site-packages"
51
+ else:
52
+ pyver = f"{sys.version_info.major}.{sys.version_info.minor}"
53
+ candidate = venv / "lib" / f"python{pyver}" / "site-packages"
54
+ return candidate if candidate.exists() else None
55
+
56
+
57
+ def _inject_venv_path(venv: Path) -> None:
58
+ site_pkg = _venv_site_packages(venv)
59
+ if site_pkg and str(site_pkg) not in sys.path:
60
+ sys.path.insert(0, str(site_pkg))
61
+
62
+
63
+ if AI_VENV.exists():
64
+ _inject_venv_path(AI_VENV)
65
+
66
+
67
+ class GitAuto:
68
+ def __init__(self, *, no_push: bool = False, no_ai: bool = False,
69
+ force_push: bool = False, branch: Optional[str] = None) -> None:
70
+ self.no_push = no_push
71
+ self.no_ai = no_ai
72
+ self.force_push = force_push
73
+ self.target_branch = branch
74
+ self.config = self._load_config()
75
+ self.interactive = sys.stdin.isatty()
76
+
77
+ def _load_config(self) -> dict:
78
+ if CONFIG_FILE.exists():
79
+ try:
80
+ return json.loads(CONFIG_FILE.read_text())
81
+ except (json.JSONDecodeError, OSError) as exc:
82
+ self._print_warning(f"Could not read config ({exc}); starting fresh.")
83
+ return {}
84
+
85
+ def _save_config(self, config: dict) -> None:
86
+ CONFIG_DIR.mkdir(exist_ok=True)
87
+ CONFIG_FILE.write_text(json.dumps(config))
88
+ try:
89
+ os.chmod(CONFIG_FILE, 0o600)
90
+ except OSError:
91
+ pass
92
+ self.config = config
93
+
94
+ @property
95
+ def api_key(self) -> Optional[str]:
96
+ return self.config.get("api_key")
97
+
98
+ @property
99
+ def provider(self) -> Optional[str]:
100
+ return self.config.get("provider")
101
+
102
+ def _print_header(self, text: str) -> None:
103
+ bar = "=" * 32
104
+ print(f"\n{Colors.HEADER}{Colors.BOLD}{bar}{Colors.END}")
105
+ print(f"{Colors.HEADER}{Colors.BOLD}{text.center(32)}{Colors.END}")
106
+ print(f"{Colors.HEADER}{Colors.BOLD}{bar}{Colors.END}\n")
107
+
108
+ def _print_success(self, text: str) -> None:
109
+ print(f"{Colors.GREEN}✓ {text}{Colors.END}")
110
+
111
+ def _print_error(self, text: str) -> None:
112
+ print(f"{Colors.RED}✗ {text}{Colors.END}")
113
+
114
+ def _print_info(self, text: str) -> None:
115
+ print(f"{Colors.CYAN}ℹ {text}{Colors.END}")
116
+
117
+ def _print_warning(self, text: str) -> None:
118
+ print(f"{Colors.YELLOW}⚠ {text}{Colors.END}")
119
+
120
+ def _run(self, command: List[str], *, capture: bool = True) -> CommandResult:
121
+ try:
122
+ if capture:
123
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
124
+ return CommandResult(result.returncode == 0, result.stdout.strip(), result.stderr.strip())
125
+ result = subprocess.run(command, check=False)
126
+ return CommandResult(result.returncode == 0, "", "")
127
+ except OSError as exc:
128
+ return CommandResult(False, "", str(exc))
129
+
130
+ def is_git_repo(self) -> bool:
131
+ return self._run(["git", "rev-parse", "--git-dir"]).ok
132
+
133
+ def get_status(self) -> str:
134
+ result = self._run(["git", "status", "--short"])
135
+ return result.stdout if result.ok else ""
136
+
137
+ def get_diff(self) -> str:
138
+ result = self._run(["git", "diff", "--cached"])
139
+ if result.ok and result.stdout:
140
+ return result.stdout
141
+ result = self._run(["git", "diff"])
142
+ return result.stdout if result.ok else ""
143
+
144
+ def get_current_branch(self) -> str:
145
+ result = self._run(["git", "branch", "--show-current"])
146
+ return result.stdout if result.ok else "main"
147
+
148
+ def get_remote_url(self) -> str:
149
+ result = self._run(["git", "remote", "get-url", "origin"])
150
+ return result.stdout if result.ok else "No remote configured"
151
+
152
+ def _ensure_ai_library(self, provider: str) -> None:
153
+ install_map = {
154
+ "openai": ("openai", "openai"),
155
+ "anthropic": ("anthropic", "anthropic"),
156
+ "gemini": ("google.genai", "google-genai"),
157
+ }
158
+ module_name, package_name = install_map[provider]
159
+ if provider == "gemini":
160
+ self._ensure_gemini_venv(package_name)
161
+ return
162
+ try:
163
+ importlib.import_module(module_name)
164
+ except ImportError:
165
+ self._print_info(f"Installing {package_name}...")
166
+ try:
167
+ subprocess.check_call(
168
+ [sys.executable, "-m", "pip", "install", "--quiet", package_name]
169
+ )
170
+ except subprocess.CalledProcessError as exc:
171
+ raise RuntimeError(f"Failed to install {package_name}: {exc}") from exc
172
+
173
+ def _ensure_gemini_venv(self, package_name: str) -> None:
174
+ if not AI_VENV.exists():
175
+ self._print_info("Creating Gemini virtual environment...")
176
+ subprocess.check_call([sys.executable, "-m", "venv", str(AI_VENV)])
177
+ try:
178
+ importlib.import_module("google.genai")
179
+ except ImportError:
180
+ self._print_info("Installing Gemini AI in venv...")
181
+ python_bin = (
182
+ AI_VENV / "Scripts" / "python.exe"
183
+ if os.name == "nt"
184
+ else AI_VENV / "bin" / "python"
185
+ )
186
+ try:
187
+ subprocess.check_call(
188
+ [str(python_bin), "-m", "pip", "install", "--quiet", package_name]
189
+ )
190
+ except subprocess.CalledProcessError as exc:
191
+ raise RuntimeError(f"Failed to install {package_name}: {exc}") from exc
192
+ _inject_venv_path(AI_VENV)
193
+
194
+ def generate_commit_message(self, diff: str) -> Optional[str]:
195
+ if not self.api_key or not self.provider:
196
+ return None
197
+ try:
198
+ self._ensure_ai_library(self.provider)
199
+ except RuntimeError as exc:
200
+ self._print_error(str(exc))
201
+ return None
202
+ prompt = (
203
+ "Generate a very short (one-line, imperative tense, <=50 chars) "
204
+ "git commit message summarising the changes below:\n\n"
205
+ f"{diff[:3000]}"
206
+ )
207
+ try:
208
+ return self._call_ai(prompt)
209
+ except Exception as exc:
210
+ self._handle_ai_error(exc)
211
+ return None
212
+
213
+ def _handle_ai_error(self, exc: Exception) -> None:
214
+ msg = str(exc).lower()
215
+ if "credit balance is too low" in msg or "insufficient_quota" in msg:
216
+ self._print_error("AI API credits exhausted.")
217
+ self._print_info("Top up your account and retry, or run: gitauto --no-ai")
218
+ if self.provider == "anthropic":
219
+ self._print_info("Billing: https://console.anthropic.com/settings/billing")
220
+ elif self.provider == "openai":
221
+ self._print_info("Billing: https://platform.openai.com/account/billing")
222
+ elif "invalid api key" in msg or "authentication" in msg or "401" in msg:
223
+ self._print_error("Invalid API key.")
224
+ self._print_info("Reconfigure with: gitauto setup")
225
+ elif "rate limit" in msg or "429" in msg:
226
+ self._print_error("AI rate limit hit.")
227
+ self._print_info("Wait a moment and retry, or run: gitauto --no-ai")
228
+ else:
229
+ self._print_error(f"AI generation failed: {exc}")
230
+
231
+ def _call_ai(self, prompt: str) -> Optional[str]:
232
+ if self.provider == "openai":
233
+ import openai
234
+ client = openai.OpenAI(api_key=self.api_key)
235
+ response = client.chat.completions.create(
236
+ model="gpt-4o-mini",
237
+ messages=[{"role": "user", "content": prompt}],
238
+ max_tokens=50,
239
+ )
240
+ return response.choices[0].message.content.strip()
241
+
242
+ if self.provider == "anthropic":
243
+ import anthropic
244
+ client = anthropic.Anthropic(api_key=self.api_key)
245
+ msg = client.messages.create(
246
+ model="claude-3-5-sonnet-20241022",
247
+ max_tokens=100,
248
+ messages=[{"role": "user", "content": prompt}],
249
+ )
250
+ return msg.content[0].text.strip()
251
+
252
+ if self.provider == "gemini":
253
+ genai = importlib.import_module("google.genai")
254
+ client = genai.Client(api_key=self.api_key)
255
+ response = client.models.generate_content(
256
+ model="gemini-2.5-flash",
257
+ contents=prompt,
258
+ )
259
+ return response.text.strip() if response.text else None
260
+
261
+ raise ValueError(f"Unknown provider: {self.provider!r}")
262
+
263
+ def _get_commit_message(self) -> Optional[str]:
264
+ use_ai = bool(self.api_key and self.provider and not self.no_ai)
265
+
266
+ if not self.interactive:
267
+ return (
268
+ self.generate_commit_message(self.get_diff()) if use_ai else None
269
+ ) or "Auto commit"
270
+
271
+ if not use_ai:
272
+ if not self.no_ai:
273
+ self._print_warning("AI not configured -- run: gitauto setup")
274
+ return input("Commit message: ").strip() or None
275
+
276
+ if input("Generate commit message with AI? (y/n) [y]: ").strip().lower() not in ("", "y"):
277
+ return input("Commit message: ").strip() or None
278
+
279
+ diff = self.get_diff()
280
+ if not diff:
281
+ self._print_warning("Nothing staged -- enter message manually.")
282
+ return input("Commit message: ").strip() or None
283
+
284
+ while True:
285
+ self._print_info(f"Generating via {self.provider}...")
286
+ msg = self.generate_commit_message(diff)
287
+
288
+ if not msg:
289
+ self._print_info("Falling back to manual input.")
290
+ return input("Commit message: ").strip() or None
291
+
292
+ print(f"\n {Colors.GREEN}{msg}{Colors.END}")
293
+ choice = input("Use this? (y / r=regenerate / m=manual) [y]: ").strip().lower() or "y"
294
+
295
+ if choice == "y":
296
+ return msg
297
+ if choice == "r":
298
+ continue
299
+ return input("Commit message: ").strip() or None
300
+
301
+ def _push(self, branch: str) -> bool:
302
+ if self.force_push:
303
+ self._print_warning("Force pushing -- this overwrites remote history.")
304
+ result = self._run(["git", "push", "--force", "origin", branch])
305
+ if result.ok:
306
+ self._print_success(f"Force-pushed to origin/{branch}")
307
+ return True
308
+ self._print_error(f"Force push failed: {result.stderr}")
309
+ return False
310
+
311
+ self._print_info(f"Pushing to origin/{branch}...")
312
+ result = self._run(["git", "push", "origin", branch])
313
+ if result.ok:
314
+ self._print_success(f"Pushed to origin/{branch}")
315
+ return True
316
+
317
+ if "has no upstream" in result.stderr or "no upstream" in result.stderr.lower():
318
+ self._print_info("New branch -- setting upstream automatically...")
319
+ result = self._run(["git", "push", "--set-upstream", "origin", branch])
320
+ if result.ok:
321
+ self._print_success(f"Pushed and upstream set for origin/{branch}")
322
+ return True
323
+ self._print_error(f"Failed to set upstream: {result.stderr}")
324
+ return False
325
+
326
+ rejected = any(
327
+ k in result.stderr.lower()
328
+ for k in ("fetch first", "non-fast-forward", "rejected")
329
+ )
330
+ if rejected:
331
+ self._print_info("Remote has new commits -- rebasing automatically...")
332
+ rebase = self._run(["git", "pull", "--rebase", "origin", branch])
333
+ if not rebase.ok:
334
+ self._print_error("Auto-rebase failed -- conflicts need manual resolution.")
335
+ self._print_info("Your repo is in rebase state. To resolve:")
336
+ self._print_info(" 1. Open conflicted files and fix the markers")
337
+ self._print_info(" 2. git add .")
338
+ self._print_info(" 3. git rebase --continue")
339
+ self._print_info(f" 4. git push origin {branch}")
340
+ self._print_info("Or to cancel: git rebase --abort")
341
+ return False
342
+
343
+ result = self._run(["git", "push", "origin", branch])
344
+ if result.ok:
345
+ self._print_success(f"Pushed to origin/{branch} after rebase.")
346
+ return True
347
+ self._print_error(f"Push failed after rebase: {result.stderr}")
348
+ return False
349
+
350
+ self._print_error(f"Push failed: {result.stderr}")
351
+ return False
352
+
353
+ def _switch_branch(self, name: str) -> str:
354
+ result = self._run(["git", "checkout", name])
355
+ if result.ok:
356
+ self._print_success(f"Switched to branch: {name}")
357
+ return name
358
+
359
+ self._print_info(f"Branch '{name}' not found -- creating it...")
360
+ result = self._run(["git", "checkout", "-b", name])
361
+ if result.ok:
362
+ self._print_success(f"Created and switched to: {name}")
363
+ return name
364
+
365
+ self._print_error(f"Could not switch/create branch '{name}': {result.stderr}")
366
+ return self.get_current_branch()
367
+
368
+ def setup_ai(self) -> None:
369
+ self._print_header("GitAuto AI Setup")
370
+ raw = input(f"Provider ({'/'.join(SUPPORTED_PROVIDERS)}): ").strip().lower()
371
+ if raw not in SUPPORTED_PROVIDERS:
372
+ self._print_warning("Unrecognised provider. Skipped.")
373
+ return
374
+ api_key = input(f"API key for {raw}: ").strip()
375
+ if not api_key:
376
+ self._print_warning("No API key entered. Skipped.")
377
+ return
378
+ self._save_config({"provider": raw, "api_key": api_key})
379
+ self._print_success(f"{raw} configured successfully.")
380
+
381
+ def run(self) -> None:
382
+ self._print_header(f"GitAuto v{__version__}")
383
+
384
+ if not self.is_git_repo():
385
+ self._print_error("Not a git repository. Run 'git init' first.")
386
+ sys.exit(1)
387
+
388
+ self._print_info(f"Remote : {self.get_remote_url()}")
389
+
390
+ current_branch = self.get_current_branch()
391
+ if self.target_branch and self.target_branch != current_branch:
392
+ current_branch = self._switch_branch(self.target_branch)
393
+
394
+ self._print_info(f"Branch : {current_branch}")
395
+
396
+ status = self.get_status()
397
+ if not status:
398
+ self._print_warning("No changes detected -- nothing to commit.")
399
+ sys.exit(0)
400
+
401
+ print(f"\n{Colors.CYAN}Changes:{Colors.END}\n{status}\n")
402
+
403
+ files = "."
404
+ if self.interactive:
405
+ files = input("Files to add (. for all) [.]: ").strip() or "."
406
+
407
+ add_cmd = ["git", "add"] + (["."] if files == "." else files.split())
408
+ result = self._run(add_cmd)
409
+ if not result.ok:
410
+ self._print_error(f"Failed to stage files: {result.stderr}")
411
+ sys.exit(1)
412
+ self._print_success(f"Staged: {files}")
413
+
414
+ commit_message = self._get_commit_message()
415
+ if not commit_message:
416
+ self._print_error("Commit message cannot be empty.")
417
+ sys.exit(1)
418
+
419
+ result = self._run(["git", "commit", "-m", commit_message])
420
+ if not result.ok:
421
+ self._print_error(f"Commit failed: {result.stderr}")
422
+ sys.exit(1)
423
+ self._print_success(f"Committed: {commit_message}")
424
+
425
+ if self.no_push:
426
+ self._print_info("Skipping push (--no-push).")
427
+ self._print_header("Done!")
428
+ return
429
+
430
+ do_push = True
431
+ if self.interactive:
432
+ do_push = input("Push to remote? (y/n) [y]: ").strip().lower() in ("", "y")
433
+
434
+ if do_push:
435
+ self._push(current_branch)
436
+
437
+ self._print_header("Done!")
438
+
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # CLI strings
443
+ # ---------------------------------------------------------------------------
444
+
445
+ HELP_TEXT = (
446
+ "\n"
447
+ "\033[95m\033[1m \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\033[0m\n"
448
+ "\033[95m\033[1m \u2551 GitAuto \u2014 AI Git Automation \u2551\033[0m\n"
449
+ "\033[95m\033[1m \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\033[0m\n"
450
+ "\n"
451
+ "\033[96m Commands:\033[0m\n"
452
+ " gitauto Run full workflow (add -> commit -> push)\n"
453
+ " gitauto setup Configure AI provider and API key\n"
454
+ " gitauto upgrade Upgrade to the latest version\n"
455
+ " gitauto uninstall Remove GitAuto from your system\n"
456
+ "\n"
457
+ "\033[96m Options:\033[0m\n"
458
+ " --no-push Commit only, skip push\n"
459
+ " --no-ai Skip AI, enter commit message manually\n"
460
+ " --force-push Force push to remote (destructive)\n"
461
+ " --branch, -b <n> Switch to or create branch before committing\n"
462
+ " -v, --version Print current version\n"
463
+ " -h, --help Show this message\n"
464
+ "\n"
465
+ "\033[96m Examples:\033[0m\n"
466
+ " gitauto Full AI-powered workflow\n"
467
+ " gitauto --no-push Commit only, no push\n"
468
+ " gitauto --branch feature/login Switch branch then commit and push\n"
469
+ " gitauto --force-push Force push current branch\n"
470
+ " gitauto --no-ai Manual commit message, no AI\n"
471
+ "\n"
472
+ "\033[93m Tip: Run 'gitauto setup' first to configure your AI provider.\033[0m\n"
473
+ )
474
+
475
+ WELCOME_TEXT = (
476
+ "\n"
477
+ "\033[92m\033[1m \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\033[0m\n"
478
+ "\033[92m\033[1m \u2551 GitAuto is ready to use! \u2551\033[0m\n"
479
+ "\033[92m\033[1m \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\033[0m\n"
480
+ "\n"
481
+ "\033[96m Available commands:\033[0m\n"
482
+ " gitauto Run full workflow (add -> commit -> push)\n"
483
+ " gitauto setup Configure AI provider and API key\n"
484
+ " gitauto upgrade Upgrade to the latest version\n"
485
+ " gitauto uninstall Remove GitAuto from your system\n"
486
+ " gitauto --no-push Commit only, skip push\n"
487
+ " gitauto --no-ai Skip AI, enter commit message manually\n"
488
+ " gitauto --force-push Force push (destructive)\n"
489
+ " gitauto --branch <n> Switch or create branch before committing\n"
490
+ " gitauto --help Show all commands and options\n"
491
+ "\n"
492
+ "\033[93m Get started: gitauto setup\033[0m\n"
493
+ )
494
+
495
+ # Known valid arguments — anything outside this set is rejected
496
+ _VALID_ARGS = {
497
+ "-v", "--version",
498
+ "-h", "--help",
499
+ "setup", "--setup",
500
+ "upgrade", "--upgrade",
501
+ "uninstall", "--uninstall",
502
+ "--no-push",
503
+ "--no-ai",
504
+ "--force-push",
505
+ "--branch", "-b",
506
+ "--pre-commit", "--commit-msg", # git hook passthrough
507
+ }
508
+
509
+ # Shell rc files the old install.sh may have written to
510
+ _RC_FILES = [
511
+ Path.home() / ".bashrc",
512
+ Path.home() / ".bash_profile",
513
+ Path.home() / ".zshrc",
514
+ ]
515
+ _OLD_PATH_LINE = 'export PATH="$HOME/.local/bin:$PATH"'
516
+ _OLD_PATH_COMMENT = "# Added by GitAuto installer"
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # CLI helpers
521
+ # ---------------------------------------------------------------------------
522
+
523
+
524
+ def _print_help() -> None:
525
+ print(HELP_TEXT)
526
+
527
+
528
+ def _first_run_check() -> None:
529
+ """Print welcome message exactly once after fresh install."""
530
+ marker = CONFIG_DIR / ".welcomed"
531
+ if not marker.exists():
532
+ print(WELCOME_TEXT)
533
+ CONFIG_DIR.mkdir(exist_ok=True)
534
+ marker.touch()
535
+
536
+
537
+ def _find_pipx() -> Optional[str]:
538
+ import shutil
539
+ return shutil.which("pipx")
540
+
541
+
542
+ def _run_pipx(*args: str) -> int:
543
+ pipx = _find_pipx()
544
+ if not pipx:
545
+ print(f"{Colors.RED}pipx not found. Install it: pip install pipx{Colors.END}")
546
+ return 1
547
+ return subprocess.run([pipx, *args], check=False).returncode
548
+
549
+
550
+ def _clean_old_rc_entries() -> None:
551
+ """Remove PATH lines written by the old install.sh from shell rc files."""
552
+ cleaned_any = False
553
+ for rcfile in _RC_FILES:
554
+ if not rcfile.exists():
555
+ continue
556
+ lines = rcfile.read_text().splitlines(keepends=True)
557
+ filtered = [
558
+ line for line in lines
559
+ if _OLD_PATH_COMMENT not in line and _OLD_PATH_LINE not in line
560
+ ]
561
+ if len(filtered) != len(lines):
562
+ rcfile.write_text("".join(filtered))
563
+ print(f"{Colors.GREEN}✓ Cleaned old PATH entry from {rcfile}{Colors.END}")
564
+ cleaned_any = True
565
+ if not cleaned_any:
566
+ print(f"{Colors.CYAN}ℹ No old PATH entries found in shell config files.{Colors.END}")
567
+
568
+
569
+ def _cmd_upgrade() -> None:
570
+ print(f"{Colors.CYAN}ℹ Upgrading GitAuto via pipx...{Colors.END}")
571
+ code = _run_pipx("upgrade", "gitauto")
572
+ if code == 0:
573
+ print(f"{Colors.GREEN}✓ GitAuto upgraded successfully.{Colors.END}")
574
+ else:
575
+ print(f"{Colors.RED}✗ Upgrade failed. Try manually: pipx upgrade gitauto{Colors.END}")
576
+ sys.exit(code)
577
+
578
+
579
+ def _cmd_uninstall() -> None:
580
+ print(f"{Colors.YELLOW}⚠ This will remove GitAuto and all its data.{Colors.END}")
581
+ confirm = input("Are you sure? (y/N): ").strip().lower()
582
+ if confirm != "y":
583
+ print(f"{Colors.CYAN}ℹ Uninstall cancelled.{Colors.END}")
584
+ sys.exit(0)
585
+
586
+ import shutil as _shutil
587
+ if CONFIG_DIR.exists():
588
+ _shutil.rmtree(CONFIG_DIR)
589
+ print(f"{Colors.GREEN}✓ Removed config: {CONFIG_DIR}{Colors.END}")
590
+
591
+ _clean_old_rc_entries()
592
+
593
+ print(f"{Colors.CYAN}ℹ Running: pipx uninstall gitauto{Colors.END}")
594
+ code = _run_pipx("uninstall", "gitauto")
595
+ if code == 0:
596
+ print(f"{Colors.GREEN}✓ GitAuto uninstalled successfully.{Colors.END}")
597
+ print(f"{Colors.CYAN}ℹ Restart your shell: exec $SHELL{Colors.END}")
598
+ else:
599
+ print(f"{Colors.RED}✗ pipx uninstall failed. Try: pipx uninstall gitauto{Colors.END}")
600
+ sys.exit(code)
601
+
602
+
603
+ # ---------------------------------------------------------------------------
604
+ # Argument parser
605
+ # ---------------------------------------------------------------------------
606
+
607
+
608
+ def _parse_args() -> dict:
609
+ args = sys.argv[1:]
610
+ opts: dict = {
611
+ "no_push": False,
612
+ "no_ai": False,
613
+ "force_push": False,
614
+ "branch": None,
615
+ "setup": False,
616
+ "version": False,
617
+ "help": False,
618
+ "upgrade": False,
619
+ "uninstall": False,
620
+ }
621
+
622
+ i = 0
623
+ while i < len(args):
624
+ a = args[i].lower()
625
+
626
+ # Reject anything that is not a known argument
627
+ if a not in _VALID_ARGS:
628
+ print(f"{Colors.RED}✗ Unknown argument: '{args[i]}'{Colors.END}")
629
+ print(f"{Colors.CYAN}ℹ Run 'gitauto --help' to see valid commands.{Colors.END}")
630
+ sys.exit(1)
631
+
632
+ if a in ("-v", "--version"):
633
+ opts["version"] = True
634
+ elif a in ("-h", "--help"):
635
+ opts["help"] = True
636
+ elif a in ("setup", "--setup"):
637
+ opts["setup"] = True
638
+ elif a in ("upgrade", "--upgrade"):
639
+ opts["upgrade"] = True
640
+ elif a in ("uninstall", "--uninstall"):
641
+ opts["uninstall"] = True
642
+ elif a == "--no-push":
643
+ opts["no_push"] = True
644
+ elif a == "--no-ai":
645
+ opts["no_ai"] = True
646
+ elif a == "--force-push":
647
+ opts["force_push"] = True
648
+ elif a in ("--branch", "-b"):
649
+ i += 1
650
+ if i >= len(args):
651
+ print(f"{Colors.RED}✗ --branch requires a branch name.{Colors.END}")
652
+ sys.exit(1)
653
+ opts["branch"] = args[i]
654
+ elif a in ("--pre-commit", "--commit-msg"):
655
+ sys.exit(0)
656
+
657
+ i += 1
658
+
659
+ return opts
660
+
661
+
662
+ # ---------------------------------------------------------------------------
663
+ # Entry point
664
+ # ---------------------------------------------------------------------------
665
+
666
+
667
+ def main() -> None:
668
+ opts = _parse_args()
669
+
670
+ if opts["help"]:
671
+ _print_help()
672
+ sys.exit(0)
673
+
674
+ if opts["version"]:
675
+ print(f"GitAuto v{__version__}")
676
+ sys.exit(0)
677
+
678
+ if opts["upgrade"]:
679
+ _cmd_upgrade()
680
+
681
+ if opts["uninstall"]:
682
+ _cmd_uninstall()
683
+
684
+ _first_run_check()
685
+
686
+ bot = GitAuto(
687
+ no_push=opts["no_push"],
688
+ no_ai=opts["no_ai"],
689
+ force_push=opts["force_push"],
690
+ branch=opts["branch"],
691
+ )
692
+
693
+ if opts["setup"]:
694
+ bot.setup_ai()
695
+ sys.exit(0)
696
+
697
+ try:
698
+ bot.run()
699
+ except KeyboardInterrupt:
700
+ print(f"\n{Colors.YELLOW}Aborted.{Colors.END}")
701
+ sys.exit(1)
702
+ except Exception as exc:
703
+ print(f"\n{Colors.RED}Unexpected error: {exc}{Colors.END}")
704
+ sys.exit(1)
705
+
706
+
707
+ if __name__ == "__main__":
708
+ main()
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitauto-cli
3
+ Version: 1.1.0
4
+ Summary: AI-powered git workflow automation
5
+ License: MIT
6
+ Keywords: git,automation,ai,cli,devtools
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Version Control :: Git
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE.md
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # GitAuto
21
+
22
+ ![Version](https://img.shields.io/badge/version-1.1.0-blue)
23
+ ![Python](https://img.shields.io/badge/Python-3.8%2B-blue)
24
+ ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)
25
+
26
+ AI-powered Git workflow automation. Stage, commit, and push in one command.
27
+
28
+ ---
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pipx install gitauto-cli
34
+ ```
35
+
36
+ > Need pipx? `pip install pipx && pipx ensurepath && exec $SHELL`
37
+
38
+ ---
39
+
40
+ ## Setup
41
+
42
+ ```bash
43
+ gitauto setup
44
+ ```
45
+
46
+ Choose a provider (OpenAI / Anthropic / Gemini) and paste your API key. Skippable — works without AI too.
47
+
48
+ ---
49
+
50
+ ## Usage
51
+
52
+ ```bash
53
+ gitauto
54
+ ```
55
+
56
+ ```
57
+ Changes:
58
+ M src/app.py
59
+
60
+ Files to add (. for all) [.]: .
61
+ ✓ Staged: .
62
+
63
+ Generate commit message with AI? (y/n) [y]: y
64
+ ℹ Generating via anthropic...
65
+
66
+ Add input validation to user registration
67
+
68
+ Use this? (y / r=regenerate / m=manual) [y]: y
69
+ ✓ Committed: Add input validation to user registration
70
+
71
+ Push to remote? (y/n) [y]: y
72
+ ✓ Pushed to origin/main
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Commands
78
+
79
+ | Command | Description |
80
+ |---------|-------------|
81
+ | `gitauto` | Full workflow — add → commit → push |
82
+ | `gitauto setup` | Configure AI provider and API key |
83
+ | `gitauto upgrade` | Upgrade to latest version |
84
+ | `gitauto uninstall` | Remove GitAuto from your system |
85
+ | `gitauto --no-push` | Commit only, skip push |
86
+ | `gitauto --no-ai` | Skip AI, type message manually |
87
+ | `gitauto --force-push` | Force push ⚠️ destructive |
88
+ | `gitauto --branch <n>` | Switch or create branch before committing |
89
+ | `gitauto --help` | Show all commands |
90
+
91
+ ---
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,7 @@
1
+ gitauto.py,sha256=FCUqRRL6hmVNNHYRnvlSGnd9reWYiQN8ACr7JZE6FcI,26479
2
+ gitauto_cli-1.1.0.dist-info/licenses/LICENSE.md,sha256=zvNKRgvo41DB3YYVH-vqDdvpgOMYKSr57FJ3r0Fs2T8,1005
3
+ gitauto_cli-1.1.0.dist-info/METADATA,sha256=oo3_ntivyqIsouiBQzoKjdjvrjgVQ55a18Z-gmunSns,2153
4
+ gitauto_cli-1.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
5
+ gitauto_cli-1.1.0.dist-info/entry_points.txt,sha256=FArNKL5QoSyQtjf1W0cG-Q3_XW9JSZXKXFDTssRJG2o,41
6
+ gitauto_cli-1.1.0.dist-info/top_level.txt,sha256=b2_wnEEruSNF-EJN5w08ofYBX0kZclHTS5dSESvuJR8,8
7
+ gitauto_cli-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitauto = gitauto:main
@@ -0,0 +1,21 @@
1
+ GitAuto – Non-Commercial License (2025)
2
+
3
+ Copyright (c) 2025 Wizdomic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to use,
7
+ copy, modify, and distribute the Software **for personal, educational, or
8
+ non-commercial purposes only**.
9
+
10
+ Commercial use, sale, or distribution for profit is **strictly prohibited**.
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
20
+ FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
+ IN THE SOFTWARE..
@@ -0,0 +1 @@
1
+ gitauto