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
|
+

|
|
23
|
+

|
|
24
|
+

|
|
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,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
|