commitstash 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {commitstash-0.3.0 → commitstash-0.4.0}/CHANGELOG.md +10 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/PKG-INFO +2 -1
- commitstash-0.4.0/autocommit/__init__.py +1 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/cli.py +13 -1
- commitstash-0.4.0/autocommit/onboarding.py +139 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/pyproject.toml +2 -1
- commitstash-0.4.0/tests/test_onboarding.py +53 -0
- commitstash-0.3.0/autocommit/__init__.py +0 -1
- {commitstash-0.3.0 → commitstash-0.4.0}/.github/workflows/ci.yml +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/.github/workflows/publish.yml +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/.gitignore +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/.pre-commit-config.yaml +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/AGENTS.md +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/LICENSE +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/README.md +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/changelog.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/config.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/explain.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/git.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/llm.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/pr.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/providers.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/review.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/secrets.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/autocommit/split.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/__init__.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/conftest.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_changelog.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_cli.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_config.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_explain.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_git.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_llm.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_pr.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_review.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_secrets.py +0 -0
- {commitstash-0.3.0 → commitstash-0.4.0}/tests/test_split.py +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: commitstash
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: AI-powered git commit message generator — reads your staged diff, writes the commit for you
|
|
5
5
|
Project-URL: Homepage, https://github.com/suryaSPS/autocommit
|
|
6
6
|
Project-URL: Issues, https://github.com/suryaSPS/autocommit/issues
|
|
7
|
+
Author-email: Surya Pratap Singh <suryasps20@gmail.com>
|
|
7
8
|
License: MIT
|
|
8
9
|
License-File: LICENSE
|
|
9
10
|
Keywords: ai,cli,commit,developer-tools,git
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -6,7 +6,7 @@ from rich.panel import Panel
|
|
|
6
6
|
from rich.text import Text
|
|
7
7
|
|
|
8
8
|
from .changelog import build_changelog
|
|
9
|
-
from .config import load_config, save_config
|
|
9
|
+
from .config import CONFIG_PATH, load_config, save_config
|
|
10
10
|
from .explain import explain as _explain
|
|
11
11
|
from .git import (
|
|
12
12
|
get_branch_commits,
|
|
@@ -26,6 +26,7 @@ from .git import (
|
|
|
26
26
|
unstage_all,
|
|
27
27
|
)
|
|
28
28
|
from .llm import generate
|
|
29
|
+
from .onboarding import is_first_run, run_onboarding
|
|
29
30
|
from .pr import write_pr
|
|
30
31
|
from .review import offline_review
|
|
31
32
|
from .review import review as _review
|
|
@@ -185,6 +186,17 @@ def cli(ctx, style, emoji, body, provider, no_ai, stage_all_files, yes):
|
|
|
185
186
|
|
|
186
187
|
config = load_config()
|
|
187
188
|
|
|
189
|
+
# First-run onboarding: only when interactive and the user hasn't already
|
|
190
|
+
# said what they want. Never in the git hook (--yes) or a non-TTY pipe.
|
|
191
|
+
if (
|
|
192
|
+
is_first_run(CONFIG_PATH)
|
|
193
|
+
and sys.stdin.isatty()
|
|
194
|
+
and not yes
|
|
195
|
+
and not provider
|
|
196
|
+
and not no_ai
|
|
197
|
+
):
|
|
198
|
+
config = run_onboarding(console, config)
|
|
199
|
+
|
|
188
200
|
# CLI flag overrides
|
|
189
201
|
if style:
|
|
190
202
|
config["style"] = style
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""First-run onboarding.
|
|
2
|
+
|
|
3
|
+
Triggers the first time someone runs `autocommit` with no saved config.
|
|
4
|
+
Walks them through picking a provider and getting a key in place.
|
|
5
|
+
|
|
6
|
+
Security: this flow NEVER writes an API key to disk. A key entered here is
|
|
7
|
+
used for the current process only (set in os.environ); to persist it we
|
|
8
|
+
print the exact shell command for the user to add themselves. This mirrors
|
|
9
|
+
the guarantee load_config/save_config already make.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
|
|
18
|
+
from .config import save_config
|
|
19
|
+
|
|
20
|
+
# provider -> (env var name, sample key prefix)
|
|
21
|
+
PROVIDER_ENV = {
|
|
22
|
+
"anthropic": ("ANTHROPIC_API_KEY", "sk-ant-..."),
|
|
23
|
+
"openai": ("OPENAI_API_KEY", "sk-..."),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
PROVIDER_CHOICES = ["anthropic", "openai", "ollama", "local"]
|
|
27
|
+
|
|
28
|
+
PROVIDER_BLURB = {
|
|
29
|
+
"anthropic": "Anthropic Claude — best quality (needs an API key)",
|
|
30
|
+
"openai": "OpenAI GPT — needs an API key",
|
|
31
|
+
"ollama": "Ollama — a local model on your machine, free, no key",
|
|
32
|
+
"local": "Offline heuristic — no AI, no key, works anywhere",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_first_run(config_path) -> bool:
|
|
37
|
+
"""True when no config file exists yet."""
|
|
38
|
+
return not config_path.exists()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _shell_rc_hint(env_name: str) -> str:
|
|
42
|
+
shell = os.environ.get("SHELL", "")
|
|
43
|
+
if shell.endswith("zsh"):
|
|
44
|
+
return "~/.zshrc"
|
|
45
|
+
if shell.endswith("bash"):
|
|
46
|
+
return "~/.bashrc"
|
|
47
|
+
return "your shell profile"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _setup_key(console: Console, provider: str) -> bool:
|
|
51
|
+
"""Ensure a usable key for provider. Returns True if AI is ready this run."""
|
|
52
|
+
env_name, sample = PROVIDER_ENV[provider]
|
|
53
|
+
|
|
54
|
+
if os.getenv(env_name):
|
|
55
|
+
console.print(f"[green]✓ {env_name} is already set in your environment.[/green]")
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
console.print(f"\n[bold]{provider.title()} needs an API key[/bold] ([dim]{env_name}[/dim]).")
|
|
59
|
+
key = click.prompt(
|
|
60
|
+
f" Paste your key ({sample}), or press Enter to skip for now",
|
|
61
|
+
hide_input=True,
|
|
62
|
+
default="",
|
|
63
|
+
show_default=False,
|
|
64
|
+
).strip()
|
|
65
|
+
|
|
66
|
+
if not key:
|
|
67
|
+
console.print("[yellow]Skipped.[/yellow] You can set it later:")
|
|
68
|
+
console.print(f" [dim]export {env_name}={sample}[/dim]")
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
# Session-only: used by this process, never persisted by us.
|
|
72
|
+
os.environ[env_name] = key
|
|
73
|
+
rc = _shell_rc_hint(env_name)
|
|
74
|
+
console.print("[green]✓ Key accepted for this session.[/green] [dim](not written to disk)[/dim]")
|
|
75
|
+
console.print(f"[dim]To keep it for next time, add this line to {rc}:[/dim]")
|
|
76
|
+
console.print(f" [dim]export {env_name}=<the key you just entered>[/dim]")
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _setup_ollama(console: Console, config: dict) -> None:
|
|
81
|
+
model = click.prompt(" Ollama model", default=config.get("ollama_model", "llama3.2"))
|
|
82
|
+
config["ollama_model"] = model
|
|
83
|
+
console.print("[dim]Make sure Ollama is running before you commit:[/dim]")
|
|
84
|
+
console.print(" [dim]ollama serve[/dim]")
|
|
85
|
+
console.print(f" [dim]ollama pull {model}[/dim]")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_onboarding(console: Console, config: dict) -> dict:
|
|
89
|
+
"""Interactive first-run setup. Returns the config to use for this run.
|
|
90
|
+
|
|
91
|
+
Persists non-secret preferences (creating the config file, so this won't
|
|
92
|
+
trigger again). If the chosen AI provider has no key available, falls back
|
|
93
|
+
to offline mode for THIS run only so the first commit still succeeds — the
|
|
94
|
+
chosen provider is still saved for next time.
|
|
95
|
+
"""
|
|
96
|
+
console.print(
|
|
97
|
+
Panel(
|
|
98
|
+
"Welcome to [bold]autocommit[/bold] 👋\n\n"
|
|
99
|
+
"Let's set up how your commit messages get generated.\n"
|
|
100
|
+
"[dim]Takes about 20 seconds. Change anything later with "
|
|
101
|
+
"[bold]autocommit configure[/bold].[/dim]",
|
|
102
|
+
title="[bold]First-time setup[/bold]",
|
|
103
|
+
border_style="cyan",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
console.print("\n[bold]Providers[/bold]")
|
|
108
|
+
for name in PROVIDER_CHOICES:
|
|
109
|
+
console.print(f" [cyan]{name}[/cyan] — {PROVIDER_BLURB[name]}")
|
|
110
|
+
console.print()
|
|
111
|
+
|
|
112
|
+
provider = click.prompt(
|
|
113
|
+
"Which provider?",
|
|
114
|
+
type=click.Choice(PROVIDER_CHOICES),
|
|
115
|
+
default="anthropic",
|
|
116
|
+
)
|
|
117
|
+
config["provider"] = provider
|
|
118
|
+
|
|
119
|
+
ready = True
|
|
120
|
+
if provider in PROVIDER_ENV:
|
|
121
|
+
ready = _setup_key(console, provider)
|
|
122
|
+
elif provider == "ollama":
|
|
123
|
+
_setup_ollama(console, config)
|
|
124
|
+
else: # local
|
|
125
|
+
console.print("[green]✓ Offline mode — no key needed.[/green]")
|
|
126
|
+
|
|
127
|
+
path = save_config(config)
|
|
128
|
+
console.print(f"\n[green]✓ Setup saved to {path}[/green]")
|
|
129
|
+
|
|
130
|
+
run_config = dict(config)
|
|
131
|
+
if provider in PROVIDER_ENV and not ready:
|
|
132
|
+
console.print(
|
|
133
|
+
"\n[yellow]No key yet — using offline mode for this commit.[/yellow] "
|
|
134
|
+
f"[dim]Next time, with {PROVIDER_ENV[provider][0]} set, it'll use {provider}.[/dim]"
|
|
135
|
+
)
|
|
136
|
+
run_config["provider"] = "local"
|
|
137
|
+
|
|
138
|
+
console.print()
|
|
139
|
+
return run_config
|
|
@@ -4,11 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "commitstash"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "AI-powered git commit message generator — reads your staged diff, writes the commit for you"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Surya Pratap Singh", email = "suryasps20@gmail.com" }]
|
|
12
13
|
keywords = ["git", "ai", "commit", "cli", "developer-tools"]
|
|
13
14
|
classifiers = [
|
|
14
15
|
"Development Status :: 4 - Beta",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
|
|
3
|
+
from autocommit import onboarding
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_is_first_run(tmp_path):
|
|
7
|
+
cfg = tmp_path / "config.json"
|
|
8
|
+
assert onboarding.is_first_run(cfg) is True
|
|
9
|
+
cfg.write_text("{}")
|
|
10
|
+
assert onboarding.is_first_run(cfg) is False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _patch_config_path(monkeypatch, tmp_path):
|
|
14
|
+
"""Point save_config at a temp file so tests never touch the real ~/.autocommit."""
|
|
15
|
+
monkeypatch.setattr("autocommit.config.CONFIG_PATH", tmp_path / "config.json")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_onboarding_local_provider(monkeypatch, tmp_path):
|
|
19
|
+
_patch_config_path(monkeypatch, tmp_path)
|
|
20
|
+
monkeypatch.setattr("click.prompt", lambda *a, **k: "local")
|
|
21
|
+
|
|
22
|
+
run_config = onboarding.run_onboarding(Console(), {})
|
|
23
|
+
assert run_config["provider"] == "local"
|
|
24
|
+
assert (tmp_path / "config.json").exists()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_onboarding_key_used_for_session_not_persisted(monkeypatch, tmp_path):
|
|
28
|
+
_patch_config_path(monkeypatch, tmp_path)
|
|
29
|
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
30
|
+
answers = iter(["anthropic", "sk-ant-secret"])
|
|
31
|
+
monkeypatch.setattr("click.prompt", lambda *a, **k: next(answers))
|
|
32
|
+
|
|
33
|
+
run_config = onboarding.run_onboarding(Console(), {})
|
|
34
|
+
|
|
35
|
+
assert run_config["provider"] == "anthropic" # real choice kept
|
|
36
|
+
assert onboarding.os.environ["ANTHROPIC_API_KEY"] == "sk-ant-secret" # session only
|
|
37
|
+
saved = (tmp_path / "config.json").read_text()
|
|
38
|
+
assert "sk-ant-secret" not in saved # never written to disk
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_onboarding_no_key_falls_back_to_local_this_run(monkeypatch, tmp_path):
|
|
42
|
+
_patch_config_path(monkeypatch, tmp_path)
|
|
43
|
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
44
|
+
answers = iter(["anthropic", ""]) # choose anthropic, skip the key
|
|
45
|
+
monkeypatch.setattr("click.prompt", lambda *a, **k: next(answers))
|
|
46
|
+
|
|
47
|
+
run_config = onboarding.run_onboarding(Console(), {})
|
|
48
|
+
|
|
49
|
+
assert run_config["provider"] == "local" # this run falls back
|
|
50
|
+
import json
|
|
51
|
+
|
|
52
|
+
saved = json.loads((tmp_path / "config.json").read_text())
|
|
53
|
+
assert saved["provider"] == "anthropic" # but the choice is remembered
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|