commitstash 0.3.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.
autocommit/config.py ADDED
@@ -0,0 +1,35 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ CONFIG_PATH = Path.home() / ".autocommit" / "config.json"
5
+
6
+ DEFAULTS = {
7
+ "provider": "anthropic",
8
+ "anthropic_model": "claude-opus-4-8",
9
+ "openai_model": "gpt-4o-mini",
10
+ "ollama_model": "llama3.2",
11
+ "ollama_host": "http://localhost:11434",
12
+ "style": "conventional",
13
+ "include_scope": True,
14
+ "include_body": False,
15
+ "emoji": False,
16
+ "max_diff_lines": 500,
17
+ "scan_secrets": True,
18
+ }
19
+
20
+
21
+ def load_config():
22
+ config = DEFAULTS.copy()
23
+ if CONFIG_PATH.exists():
24
+ with open(CONFIG_PATH) as f:
25
+ config.update(json.load(f))
26
+ return config
27
+
28
+
29
+ def save_config(config):
30
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
31
+ # Never persist API keys to disk
32
+ safe = {k: v for k, v in config.items() if "api_key" not in k}
33
+ with open(CONFIG_PATH, "w") as f:
34
+ json.dump(safe, f, indent=2)
35
+ return CONFIG_PATH
autocommit/explain.py ADDED
@@ -0,0 +1,43 @@
1
+ """Explain the staged diff in plain language.
2
+
3
+ Needs a real model — there is no offline mode; the heuristic can classify
4
+ a change but cannot explain intent.
5
+ """
6
+
7
+ from .llm import LOCAL_PROVIDERS, complete
8
+
9
+ EXPLAIN_PROMPT = """Explain this staged git diff to a reviewer who hasn't seen the codebase today.
10
+
11
+ Changed files:
12
+ {files}
13
+
14
+ Staged diff:
15
+ ```
16
+ {diff}
17
+ ```
18
+
19
+ Output exactly these markdown sections:
20
+
21
+ ## What changed
22
+ <2-4 sentences describing the change in plain language>
23
+
24
+ ## Why (inferred)
25
+ <the most likely motivation, inferred from the code — say "unclear" if it is>
26
+
27
+ ## Impact
28
+ <what behavior changes for users or callers>
29
+
30
+ ## Risk
31
+ <Low, Medium, or High — one line explaining the rating; call out missing tests>
32
+
33
+ Rules:
34
+ - Plain language, no file-by-file narration
35
+ - Be honest about uncertainty; never invent intent the diff doesn't support"""
36
+
37
+
38
+ def explain(diff, files, config):
39
+ """Return a markdown explanation, or None if the provider is offline-only."""
40
+ if config.get("provider") in LOCAL_PROVIDERS:
41
+ return None
42
+ files_str = "\n".join(f" - {f}" for f in files)
43
+ return complete(EXPLAIN_PROMPT.format(files=files_str, diff=diff), config, max_tokens=1500)
autocommit/git.py ADDED
@@ -0,0 +1,167 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+
5
+ def is_git_repo():
6
+ result = subprocess.run(
7
+ ["git", "rev-parse", "--is-inside-work-tree"],
8
+ capture_output=True,
9
+ text=True,
10
+ )
11
+ return result.returncode == 0
12
+
13
+
14
+ def get_staged_diff():
15
+ result = subprocess.run(
16
+ ["git", "diff", "--cached"],
17
+ capture_output=True,
18
+ text=True,
19
+ )
20
+ if result.returncode != 0:
21
+ return None, result.stderr
22
+ return result.stdout, None
23
+
24
+
25
+ def get_staged_files():
26
+ result = subprocess.run(
27
+ ["git", "diff", "--cached", "--name-only"],
28
+ capture_output=True,
29
+ text=True,
30
+ )
31
+ if result.returncode != 0:
32
+ return [], result.stderr
33
+ files = [f for f in result.stdout.strip().split("\n") if f]
34
+ return files, None
35
+
36
+
37
+ def stage_all():
38
+ result = subprocess.run(["git", "add", "-A"], capture_output=True, text=True)
39
+ return result.returncode == 0
40
+
41
+
42
+ def make_commit(message):
43
+ result = subprocess.run(
44
+ ["git", "commit", "-m", message],
45
+ capture_output=True,
46
+ text=True,
47
+ )
48
+ return result.returncode == 0, result.stdout, result.stderr
49
+
50
+
51
+ def get_current_branch():
52
+ result = subprocess.run(
53
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
54
+ capture_output=True,
55
+ text=True,
56
+ )
57
+ return result.stdout.strip() if result.returncode == 0 else None
58
+
59
+
60
+ def get_default_branch():
61
+ """Best-effort base branch: origin/HEAD if set, else main/master if they exist."""
62
+ result = subprocess.run(
63
+ ["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
64
+ capture_output=True,
65
+ text=True,
66
+ )
67
+ if result.returncode == 0:
68
+ return result.stdout.strip().removeprefix("origin/")
69
+ for name in ("main", "master"):
70
+ check = subprocess.run(
71
+ ["git", "rev-parse", "--verify", "--quiet", name],
72
+ capture_output=True,
73
+ text=True,
74
+ )
75
+ if check.returncode == 0:
76
+ return name
77
+ return None
78
+
79
+
80
+ def get_branch_commits(base):
81
+ """Commit subjects on HEAD that are not on base, oldest first."""
82
+ result = subprocess.run(
83
+ ["git", "log", "--reverse", "--pretty=%s", f"{base}..HEAD"],
84
+ capture_output=True,
85
+ text=True,
86
+ )
87
+ if result.returncode != 0:
88
+ return [], result.stderr
89
+ return [ln for ln in result.stdout.strip().split("\n") if ln], None
90
+
91
+
92
+ def get_branch_diff(base):
93
+ """Diff of HEAD against the merge-base with base (what a PR would show)."""
94
+ result = subprocess.run(
95
+ ["git", "diff", f"{base}...HEAD"],
96
+ capture_output=True,
97
+ text=True,
98
+ )
99
+ if result.returncode != 0:
100
+ return None, result.stderr
101
+ return result.stdout, None
102
+
103
+
104
+ def get_recent_commit_subjects(n=20):
105
+ """Subjects of the last n commits, newest first. Empty list on any failure."""
106
+ result = subprocess.run(
107
+ ["git", "log", f"-{n}", "--pretty=%s"],
108
+ capture_output=True,
109
+ text=True,
110
+ )
111
+ if result.returncode != 0:
112
+ return []
113
+ return [ln for ln in result.stdout.strip().split("\n") if ln]
114
+
115
+
116
+ def get_last_tag():
117
+ result = subprocess.run(
118
+ ["git", "describe", "--tags", "--abbrev=0"],
119
+ capture_output=True,
120
+ text=True,
121
+ )
122
+ return result.stdout.strip() if result.returncode == 0 else None
123
+
124
+
125
+ def get_commit_subjects_since(ref):
126
+ """Subjects after ref (exclusive) up to HEAD, oldest first. ref=None -> all commits."""
127
+ rev_range = f"{ref}..HEAD" if ref else "HEAD"
128
+ result = subprocess.run(
129
+ ["git", "log", "--reverse", "--pretty=%s", rev_range],
130
+ capture_output=True,
131
+ text=True,
132
+ )
133
+ if result.returncode != 0:
134
+ return [], result.stderr
135
+ return [ln for ln in result.stdout.strip().split("\n") if ln], None
136
+
137
+
138
+ def get_unstaged_files():
139
+ result = subprocess.run(
140
+ ["git", "diff", "--name-only"],
141
+ capture_output=True,
142
+ text=True,
143
+ )
144
+ if result.returncode != 0:
145
+ return []
146
+ return [f for f in result.stdout.strip().split("\n") if f]
147
+
148
+
149
+ def unstage_all():
150
+ result = subprocess.run(["git", "reset", "-q"], capture_output=True, text=True)
151
+ return result.returncode == 0
152
+
153
+
154
+ def stage_files(files):
155
+ result = subprocess.run(["git", "add", "--", *files], capture_output=True, text=True)
156
+ return result.returncode == 0
157
+
158
+
159
+ def get_repo_name():
160
+ result = subprocess.run(
161
+ ["git", "rev-parse", "--show-toplevel"],
162
+ capture_output=True,
163
+ text=True,
164
+ )
165
+ if result.returncode == 0:
166
+ return Path(result.stdout.strip()).name
167
+ return None
autocommit/llm.py ADDED
@@ -0,0 +1,298 @@
1
+ import os
2
+ from pathlib import PurePosixPath
3
+
4
+ from . import providers
5
+
6
+
7
+ STYLE_INSTRUCTIONS = {
8
+ "conventional": """\
9
+ Generate a conventional commit message.
10
+
11
+ Format: <type>(<scope>): <description>
12
+
13
+ Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build
14
+ - feat: a new feature
15
+ - fix: a bug fix
16
+ - refactor: code change that neither fixes a bug nor adds a feature
17
+ - chore: tooling, deps, config changes
18
+ - docs: documentation only
19
+ - perf: performance improvement
20
+ - test: adding or fixing tests
21
+
22
+ Rules:
23
+ - First line must be under 72 characters
24
+ - Description is lowercase, no trailing period
25
+ - Scope is the module or component affected (e.g. auth, orders, cli)""",
26
+ "angular": """\
27
+ Generate an Angular-style commit message.
28
+
29
+ Format: <type>(<scope>): <subject>
30
+
31
+ Types: feat, fix, docs, style, refactor, test, chore
32
+ Subject is imperative, present tense, lowercase, no trailing period.
33
+ First line under 72 characters.""",
34
+ "simple": """\
35
+ Generate a short, plain-English commit message.
36
+ No special format. Imperative mood. Under 72 characters.
37
+ Example: "Add user authentication" or "Fix null pointer in order view" """,
38
+ }
39
+
40
+
41
+ def _build_prompt(diff, files, config, recent_subjects=None):
42
+ style = config.get("style", "conventional")
43
+ include_scope = config.get("include_scope", True)
44
+ include_body = config.get("include_body", False)
45
+ emoji = config.get("emoji", False)
46
+
47
+ files_str = "\n".join(f" - {f}" for f in files)
48
+ style_block = STYLE_INSTRUCTIONS.get(style, STYLE_INSTRUCTIONS["conventional"])
49
+
50
+ recent_block = ""
51
+ if recent_subjects:
52
+ joined = "\n".join(f" {s}" for s in recent_subjects[:20])
53
+ recent_block = (
54
+ "\nRecent commit messages in this repository — match their tone, "
55
+ f"scope naming, and conventions:\n{joined}\n"
56
+ )
57
+
58
+ scope_note = "" if include_scope else "Do NOT include a scope."
59
+ body_note = (
60
+ "After the first line, add one blank line then a short body explaining WHY (not what) the change was made."
61
+ if include_body
62
+ else "Do NOT include a body. First line only."
63
+ )
64
+ emoji_note = (
65
+ "Prepend a single relevant emoji before the type (e.g. ✨ feat, 🐛 fix, ♻️ refactor)."
66
+ if emoji
67
+ else ""
68
+ )
69
+
70
+ return f"""You are an expert developer writing a git commit message.
71
+
72
+ Changed files:
73
+ {files_str}
74
+
75
+ Staged diff:
76
+ ```
77
+ {diff}
78
+ ```
79
+
80
+ {style_block}
81
+ {scope_note}
82
+ {body_note}
83
+ {emoji_note}
84
+ {recent_block}
85
+
86
+ Additional rules:
87
+ - Be specific, not generic ("fix bug" is bad, "fix null check in order serializer" is good)
88
+ - Output ONLY the commit message — no explanation, no markdown, no backticks
89
+ - Never include Co-Authored-By lines"""
90
+
91
+
92
+ LOCAL_PROVIDERS = ("local", "none", "heuristic")
93
+
94
+
95
+ def complete(prompt, config, max_tokens=1024):
96
+ """Send a prompt to the configured AI provider and return the text response.
97
+
98
+ Shared by every AI feature (commit messages, review, PR descriptions).
99
+ Local/heuristic mode has no completion backend — callers handle it themselves.
100
+ """
101
+ return providers.get(config.get("provider", "anthropic")).complete(prompt, config, max_tokens)
102
+
103
+
104
+ def generate(diff, files, config, recent_subjects=None):
105
+ provider = config.get("provider", "anthropic")
106
+ if provider in LOCAL_PROVIDERS:
107
+ return _heuristic(diff, files, config)
108
+ return complete(_build_prompt(diff, files, config, recent_subjects), config, max_tokens=300)
109
+
110
+
111
+ # ──────────────────────────────────────────────────────────────────────────────
112
+ # Local heuristic generator — no API key, no network, works fully offline
113
+ # ──────────────────────────────────────────────────────────────────────────────
114
+
115
+ EMOJI = {
116
+ "feat": "✨",
117
+ "fix": "🐛",
118
+ "docs": "📝",
119
+ "refactor": "♻️",
120
+ "test": "✅",
121
+ "chore": "🔧",
122
+ }
123
+
124
+ GENERIC_DIRS = {"src", "lib", "app", "source", "pkg", "internal", "."}
125
+
126
+ DOC_STEMS = {"readme", "license", "licence", "changelog", "contributing", "authors", "notice"}
127
+ DOC_EXTS = {".md", ".rst", ".txt", ".adoc"}
128
+
129
+ CONFIG_EXTS = {".toml", ".yml", ".yaml", ".ini", ".cfg", ".lock", ".json", ".conf"}
130
+ CONFIG_NAMES = {
131
+ "dockerfile",
132
+ "makefile",
133
+ ".gitignore",
134
+ ".dockerignore",
135
+ ".editorconfig",
136
+ "requirements.txt",
137
+ "pyproject.toml",
138
+ "setup.py",
139
+ "setup.cfg",
140
+ "package.json",
141
+ "package-lock.json",
142
+ "yarn.lock",
143
+ "poetry.lock",
144
+ "cargo.toml",
145
+ "go.mod",
146
+ "go.sum",
147
+ }
148
+
149
+
150
+ def _is_test(path):
151
+ p = path.lower()
152
+ name = PurePosixPath(p).name
153
+ return (
154
+ "/test" in p
155
+ or p.startswith("test")
156
+ or "/tests/" in p
157
+ or name.startswith("test_")
158
+ or name.endswith("_test.py")
159
+ or ".test." in name
160
+ or ".spec." in name
161
+ )
162
+
163
+
164
+ def _is_doc(path):
165
+ p = PurePosixPath(path.lower())
166
+ return p.stem in DOC_STEMS or p.suffix in DOC_EXTS or "docs/" in path.lower()
167
+
168
+
169
+ def _is_config(path):
170
+ p = PurePosixPath(path.lower())
171
+ return p.name in CONFIG_NAMES or p.suffix in CONFIG_EXTS
172
+
173
+
174
+ def _parse_diff_status(diff):
175
+ """Map each file path to 'A' (added), 'D' (deleted), or 'M' (modified)."""
176
+ status = {}
177
+ current = None
178
+ for line in diff.split("\n"):
179
+ if line.startswith("diff --git "):
180
+ _, _, rest = line.partition(" b/")
181
+ current = rest or None
182
+ if current:
183
+ status[current] = "M"
184
+ elif current and line.startswith("new file mode"):
185
+ status[current] = "A"
186
+ elif current and line.startswith("deleted file mode"):
187
+ status[current] = "D"
188
+ return status
189
+
190
+
191
+ def _count_lines(diff):
192
+ adds = sum(1 for ln in diff.split("\n") if ln.startswith("+") and not ln.startswith("+++"))
193
+ dels = sum(1 for ln in diff.split("\n") if ln.startswith("-") and not ln.startswith("---"))
194
+ return adds, dels
195
+
196
+
197
+ def _infer_type(files, added, deleted, adds, dels):
198
+ if files and all(_is_test(f) for f in files):
199
+ return "test"
200
+ if files and all(_is_doc(f) for f in files):
201
+ return "docs"
202
+ if files and all(_is_config(f) for f in files):
203
+ return "chore"
204
+ if added and not [f for f in files if f not in added]:
205
+ return "feat" # only brand-new files
206
+ if deleted and len(deleted) == len(files):
207
+ return "chore" # pure removals
208
+ if adds > dels * 3:
209
+ return "feat"
210
+ if dels > adds * 3:
211
+ return "refactor"
212
+ return "fix"
213
+
214
+
215
+ def _deepest_scope(parts):
216
+ for comp in reversed(list(parts)):
217
+ c = comp.lower()
218
+ if c and c not in GENERIC_DIRS and not c.startswith("."):
219
+ return c
220
+ return None
221
+
222
+
223
+ def _infer_scope(files):
224
+ if len(files) == 1:
225
+ return _deepest_scope(PurePosixPath(files[0]).parent.parts)
226
+ try:
227
+ common = os.path.commonpath(files)
228
+ except ValueError:
229
+ return None
230
+ return _deepest_scope([p for p in common.split("/") if p])
231
+
232
+
233
+ def _display_name(path):
234
+ p = PurePosixPath(path)
235
+ return p.stem or p.name
236
+
237
+
238
+ def _join(items):
239
+ if len(items) == 1:
240
+ return items[0]
241
+ if len(items) == 2:
242
+ return f"{items[0]} and {items[1]}"
243
+ return ", ".join(items[:-1]) + f", and {items[-1]}"
244
+
245
+
246
+ def _name_list(files, limit=2):
247
+ names = list(dict.fromkeys(_display_name(f) for f in files))
248
+ if len(names) <= limit:
249
+ return _join(names)
250
+ return f"{len(files)} files"
251
+
252
+
253
+ def _build_subject(ctype, files, added, deleted):
254
+ if deleted and len(deleted) == len(files):
255
+ return f"remove {_name_list(deleted)}"
256
+
257
+ verb = {
258
+ "feat": "add",
259
+ "fix": "fix",
260
+ "docs": "update",
261
+ "test": "add",
262
+ "chore": "update",
263
+ "refactor": "refactor",
264
+ }.get(ctype, "update")
265
+
266
+ if len(files) == 1:
267
+ name = _display_name(files[0])
268
+ if files[0] in added:
269
+ return f"add {name}"
270
+ return f"{verb} {name}"
271
+
272
+ return f"{verb} {_name_list(files)}"
273
+
274
+
275
+ def _format_message(ctype, scope, subject, config):
276
+ style = config.get("style", "conventional")
277
+ if style == "simple":
278
+ msg = subject[:1].upper() + subject[1:]
279
+ else:
280
+ head = f"{ctype}({scope})" if scope else ctype
281
+ prefix = f"{EMOJI[ctype]} " if config.get("emoji") and ctype in EMOJI else ""
282
+ msg = f"{prefix}{head}: {subject}"
283
+ return msg[:72]
284
+
285
+
286
+ def _heuristic(diff, files, config):
287
+ status = _parse_diff_status(diff)
288
+ for f in files: # binary / untracked files may not appear in the diff body
289
+ status.setdefault(f, "M")
290
+
291
+ added = [f for f in files if status.get(f) == "A"]
292
+ deleted = [f for f in files if status.get(f) == "D"]
293
+ adds, dels = _count_lines(diff)
294
+
295
+ ctype = _infer_type(files, added, deleted, adds, dels)
296
+ scope = _infer_scope(files) if config.get("include_scope", True) else None
297
+ subject = _build_subject(ctype, files, added, deleted)
298
+ return _format_message(ctype, scope, subject, config)
autocommit/pr.py ADDED
@@ -0,0 +1,78 @@
1
+ """PR description writer.
2
+
3
+ Builds a title + markdown body from the branch's commits and diff against
4
+ the base branch. AI providers write the prose; local mode assembles a
5
+ serviceable description from the commit subjects alone.
6
+ """
7
+
8
+ from .llm import LOCAL_PROVIDERS, complete
9
+
10
+ PR_PROMPT = """Write a pull request title and description for this branch.
11
+
12
+ Branch: {branch} (base: {base})
13
+
14
+ Commits on this branch, oldest first:
15
+ {commits}
16
+
17
+ Full diff against the base branch:
18
+ ```
19
+ {diff}
20
+ ```
21
+
22
+ Output format — exactly this structure, no other text:
23
+
24
+ TITLE: <one line, under 72 characters, imperative mood>
25
+
26
+ ## Summary
27
+ <2-4 sentences: what this PR does and why>
28
+
29
+ ## Changes
30
+ <bulleted list of the concrete changes, grouped logically>
31
+
32
+ ## Testing
33
+ <how these changes were or should be verified; write "Not specified" if the diff contains no tests>
34
+
35
+ Rules:
36
+ - Describe WHAT changed and WHY, not a file-by-file narration
37
+ - No marketing language, no filler
38
+ - Mention breaking changes prominently if any"""
39
+
40
+
41
+ def _offline_pr(branch, base, commits):
42
+ title = commits[-1] if commits else f"Merge {branch} into {base}"
43
+ if len(title) > 72:
44
+ title = title[:69] + "..."
45
+ bullets = "\n".join(f"- {c}" for c in commits) or "- (no commits found)"
46
+ body = (
47
+ "## Summary\n"
48
+ f"Changes from `{branch}` targeting `{base}` "
49
+ f"({len(commits)} commit{'s' if len(commits) != 1 else ''}).\n\n"
50
+ "## Changes\n"
51
+ f"{bullets}\n\n"
52
+ "## Testing\n"
53
+ "Not specified.\n"
54
+ )
55
+ return title, body
56
+
57
+
58
+ def write_pr(branch, base, commits, diff, config):
59
+ """Return (title, body)."""
60
+ if config.get("provider") in LOCAL_PROVIDERS:
61
+ return _offline_pr(branch, base, commits)
62
+
63
+ commits_str = "\n".join(f" - {c}" for c in commits) or " (none)"
64
+ raw = complete(
65
+ PR_PROMPT.format(branch=branch, base=base, commits=commits_str, diff=diff),
66
+ config,
67
+ max_tokens=2000,
68
+ )
69
+
70
+ title, body = "", raw
71
+ for i, line in enumerate(raw.split("\n")):
72
+ if line.startswith("TITLE:"):
73
+ title = line.removeprefix("TITLE:").strip()
74
+ body = "\n".join(raw.split("\n")[i + 1 :]).strip()
75
+ break
76
+ if not title:
77
+ title, _ = _offline_pr(branch, base, commits)
78
+ return title, body