commit-me 0.5.1__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.
commit_me/__init__.py ADDED
File without changes
commit_me/ai.py ADDED
@@ -0,0 +1,199 @@
1
+ """Generate commit messages by streaming an opencode session."""
2
+
3
+ import pathlib
4
+ from collections.abc import Callable
5
+ from typing import Any, TypedDict
6
+
7
+ from opencode import Opencode
8
+ from opencode._stream_events import (
9
+ MessagePartDeltaProps,
10
+ MessagePartUpdatedProps,
11
+ MessageUpdatedProps,
12
+ SessionStatusProps,
13
+ parse_stream_event,
14
+ )
15
+
16
+ from commit_me.util.constants import PROMPT
17
+
18
+ type Callback = Callable[[], None]
19
+ type ProgressCallback = Callable[[str], None]
20
+ type EventCallback = Callable[[dict[str, Any]], None]
21
+
22
+ Handlers = TypedDict(
23
+ "Handlers",
24
+ {
25
+ "message.updated": EventCallback,
26
+ "message.part.updated": EventCallback,
27
+ "message.part.delta": EventCallback,
28
+ "session.status": EventCallback,
29
+ },
30
+ )
31
+
32
+
33
+ def make_handler(callback: EventCallback) -> EventCallback:
34
+ """Wrap an event callback into a stream event handler.
35
+
36
+ Args:
37
+ callback: Callback invoked with the event properties.
38
+
39
+ Returns:
40
+ A handler suitable for the handlers mapping.
41
+ """
42
+
43
+ def handler(props: dict[str, Any]) -> None:
44
+ callback(props)
45
+
46
+ return handler
47
+
48
+
49
+ def generate_commit_message(
50
+ opencode_binary: str,
51
+ directory: pathlib.Path,
52
+ model: str,
53
+ *,
54
+ on_start: Callback,
55
+ on_thinking: Callback,
56
+ on_progress: ProgressCallback,
57
+ on_finish: Callback,
58
+ ) -> str:
59
+ """Generate a Conventional Commit message from the staged changes.
60
+
61
+ Starts an opencode session in the given directory, streams its response,
62
+ and assembles the assistant's text parts into a single commit message.
63
+
64
+ Args:
65
+ opencode_binary: Path to the opencode executable.
66
+ directory: Directory of the Git repository to analyze.
67
+ on_start: Called before the opencode session starts.
68
+ on_thinking: Called once when opencode starts thinking.
69
+ on_progress: Called with each streamed text delta.
70
+ on_finish: Called once the message has been assembled.
71
+
72
+ Returns:
73
+ The generated commit message.
74
+ """
75
+ assistant_message_ids: set[str] = set()
76
+ part_message_ids: dict[str, str] = {}
77
+ part_texts: dict[str, str] = {}
78
+ part_deltas: dict[str, list[str]] = {}
79
+ part_order: list[str] = []
80
+
81
+ thinking_notified = False
82
+
83
+ def notify_thinking() -> None:
84
+ nonlocal thinking_notified
85
+
86
+ if not thinking_notified:
87
+ thinking_notified = True
88
+ on_thinking()
89
+
90
+ def handle_message_updated(props: dict[str, Any]) -> None:
91
+ event = MessageUpdatedProps.model_construct(**props)
92
+ info = event.info
93
+
94
+ if info.get("role") == "assistant":
95
+ message_id = info.get("id")
96
+ if message_id:
97
+ assistant_message_ids.add(message_id)
98
+
99
+ notify_thinking()
100
+
101
+ def handle_part_updated(props: dict[str, Any]) -> None:
102
+ event = MessagePartUpdatedProps.model_construct(**props)
103
+ part = event.part
104
+
105
+ if part.get("type") != "text":
106
+ return
107
+
108
+ part_id = part.get("id")
109
+ message_id = part.get("messageID")
110
+
111
+ if not part_id:
112
+ return
113
+
114
+ if part_id not in part_order:
115
+ part_order.append(part_id)
116
+
117
+ if message_id:
118
+ part_message_ids[part_id] = message_id
119
+
120
+ if "text" in part:
121
+ part_texts[part_id] = part.get("text", "")
122
+
123
+ def handle_part_delta(props: dict[str, Any]) -> None:
124
+ event = MessagePartDeltaProps.model_construct(**props)
125
+
126
+ if event.field != "text" or not event.delta:
127
+ return
128
+
129
+ part_id = event.partID
130
+ part_deltas.setdefault(part_id, []).append(event.delta)
131
+
132
+ on_progress(event.delta)
133
+
134
+ def handle_status(props: dict[str, Any]) -> None:
135
+ event = SessionStatusProps.model_construct(**props)
136
+ status = event.status.get("type")
137
+
138
+ if status == "busy":
139
+ notify_thinking()
140
+
141
+ handlers: Handlers = {
142
+ "message.updated": make_handler(handle_message_updated),
143
+ "message.part.updated": make_handler(handle_part_updated),
144
+ "message.part.delta": make_handler(handle_part_delta),
145
+ "session.status": make_handler(handle_status),
146
+ }
147
+
148
+ on_start()
149
+
150
+ with Opencode(
151
+ opencode_binary=opencode_binary,
152
+ directory=str(directory),
153
+ model=model,
154
+ ) as oc:
155
+ session = oc.create_session()
156
+ response = oc.client.event_subscribe()
157
+
158
+ oc.client.session_send(
159
+ session.id,
160
+ {"parts": [{"type": "text", "text": PROMPT}]},
161
+ )
162
+
163
+ for line in response.iter_lines():
164
+ if not line.startswith("data: "):
165
+ continue
166
+
167
+ event = parse_stream_event(line[6:])
168
+ props = event.properties
169
+
170
+ if props.get("sessionID") not in (None, session.id):
171
+ continue
172
+
173
+ handler = handlers.get(event.type)
174
+ if handler:
175
+ handler(props)
176
+
177
+ if event.type == "session.idle":
178
+ break
179
+
180
+ if event.type == "session.status":
181
+ status = SessionStatusProps.model_construct(**props)
182
+ if status.status.get("type") == "idle":
183
+ break
184
+ response.close()
185
+
186
+ result_parts: list[str] = []
187
+
188
+ for part_id in part_order:
189
+ if part_message_ids.get(part_id) not in assistant_message_ids:
190
+ continue
191
+
192
+ if part_id in part_texts:
193
+ result_parts.append(part_texts[part_id])
194
+ else:
195
+ result_parts.append("".join(part_deltas.get(part_id, [])))
196
+
197
+ on_finish()
198
+
199
+ return "".join(result_parts).strip()
commit_me/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ """Command-line entry point for commit-me."""
2
+
3
+ import pathlib
4
+
5
+ import rich_click as click
6
+ from rich.console import Console
7
+
8
+ from commit_me.ai import generate_commit_message
9
+ from commit_me.exceptions import MissingCliError, NoGitRepoError, NoStagedChangesError
10
+ from commit_me.util.command import check_required_clis, create_commit, has_stage_changes
11
+
12
+
13
+ @click.command()
14
+ @click.argument(
15
+ "directory",
16
+ type=click.Path(exists=True, path_type=pathlib.Path),
17
+ default=pathlib.Path.cwd(),
18
+ )
19
+ @click.option("-p", "--preview", is_flag=True)
20
+ @click.option("-m", "--model", default="opencode/big-pickle")
21
+ def commit_me_cli(directory: pathlib.Path, preview: bool, model: str) -> None:
22
+ """Generate a Conventional Commit message with opencode and commit it.
23
+
24
+ Args:
25
+ directory: Existing directory of the Git repository to commit in.
26
+ preview: Whether to preview the commit message before committing.
27
+ """
28
+ try:
29
+ clis = check_required_clis()
30
+
31
+ contains_stage = has_stage_changes(directory)
32
+
33
+ if not contains_stage:
34
+ raise NoStagedChangesError(directory=directory)
35
+
36
+ console = Console()
37
+ with console.status("Calling opencode") as status:
38
+ commit_message = generate_commit_message(
39
+ opencode_binary=clis["opencode"]["path"],
40
+ directory=directory,
41
+ model=model,
42
+ on_start=lambda: status.update("Starting opencode"),
43
+ on_thinking=lambda: status.update("Opencode is thinking..."),
44
+ on_progress=lambda _e: None,
45
+ on_finish=lambda: status.update("Done"),
46
+ )
47
+
48
+ create_commit(
49
+ message=commit_message,
50
+ directory=directory,
51
+ preview=preview,
52
+ )
53
+ except (MissingCliError, NoGitRepoError, NoStagedChangesError) as exception:
54
+ click.echo(exception, err=True)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ commit_me_cli()
@@ -0,0 +1,56 @@
1
+ """Exceptions raised by the commit-me package."""
2
+
3
+ import pathlib
4
+
5
+
6
+ class MissingCliError(Exception):
7
+ """Raised when one or more required CLIs are missing or not on the PATH."""
8
+
9
+ MESSAGE_TEMPLATE: str = (
10
+ "Required CLI(s) not found on PATH: {cli_names}."
11
+ " Install them and make sure they are available in your shell."
12
+ )
13
+ cli_names: list[str]
14
+
15
+ def __init__(self, *, cli_names: list[str]) -> None:
16
+ """Initialize the error with the names of the missing CLIs.
17
+
18
+ Args:
19
+ cli_names: Names of the CLIs that are missing.
20
+ """
21
+ self.cli_names = cli_names
22
+ super().__init__(self.MESSAGE_TEMPLATE.format(cli_names=", ".join(cli_names)))
23
+
24
+
25
+ class NoGitRepoError(Exception):
26
+ """Raised when the target directory is not inside a Git repository."""
27
+
28
+ MESSAGE = (
29
+ "No Git repository found in: {directory}."
30
+ " Run inside a repository or pass a path to one."
31
+ )
32
+
33
+ def __init__(self, *, directory: pathlib.Path) -> None:
34
+ """Initialize the error with the directory that is not a repository.
35
+
36
+ Args:
37
+ directory: Directory that was checked for a Git repository.
38
+ """
39
+ super().__init__(self.MESSAGE.format(directory=str(directory.absolute())))
40
+
41
+
42
+ class NoStagedChangesError(Exception):
43
+ """Raised when the repository has no staged changes to commit."""
44
+
45
+ MESSAGE = (
46
+ "No staged changes found in: {directory}."
47
+ " Stage your changes first with `git add`."
48
+ )
49
+
50
+ def __init__(self, *, directory: pathlib.Path) -> None:
51
+ """Initialize the error with the directory that has no staged changes.
52
+
53
+ Args:
54
+ directory: Directory of the repository that was checked.
55
+ """
56
+ super().__init__(self.MESSAGE.format(directory=str(directory.absolute())))
File without changes
@@ -0,0 +1,99 @@
1
+ """Utilities for detecting required command-line tools."""
2
+
3
+ import pathlib
4
+ import shutil
5
+ import subprocess
6
+ from typing import Never, TypedDict
7
+
8
+ import rich_click as click
9
+
10
+ from commit_me.exceptions import MissingCliError, NoGitRepoError
11
+
12
+
13
+ class Cli(TypedDict):
14
+ """Metadata for a command-line tool that must be available."""
15
+
16
+ name: str
17
+ cmd: str
18
+
19
+
20
+ REQUIRED_CLIS: list[Cli] = [
21
+ {"name": "Git", "cmd": "git"},
22
+ {"name": "Opencode", "cmd": "opencode"},
23
+ ]
24
+
25
+
26
+ def is_cli_installed(cli_info: Cli) -> bool:
27
+ """Return whether the given CLI is present on the PATH.
28
+
29
+ Args:
30
+ cli_info: Metadata of the CLI to look up.
31
+
32
+ Returns:
33
+ True if the CLI command was found on the PATH.
34
+ """
35
+ return bool(shutil.which(cli_info["cmd"]))
36
+
37
+
38
+ def check_required_clis() -> dict[str, dict] | Never:
39
+ """Raise MissingCliError if any required CLI is not installed."""
40
+ missing_clis = [cli["name"] for cli in REQUIRED_CLIS if not is_cli_installed(cli)]
41
+ if missing_clis:
42
+ raise MissingCliError(cli_names=missing_clis)
43
+
44
+ return {cli["cmd"]: {"path": shutil.which(cli["cmd"])} for cli in REQUIRED_CLIS}
45
+
46
+
47
+ def create_commit(
48
+ *,
49
+ message: str,
50
+ directory: pathlib.Path,
51
+ preview: bool,
52
+ ) -> None:
53
+ """Create a Git commit with the given message, optionally after a preview.
54
+
55
+ Args:
56
+ message: Commit message to use.
57
+ directory: Directory of the Git repository to commit in.
58
+ preview: Whether to show the message and ask for confirmation first.
59
+ """
60
+
61
+ def git_commit() -> None:
62
+ """Run `git commit` with the message in the target directory."""
63
+ subprocess.run(
64
+ args=["git", "commit", "-m", f"{message}"],
65
+ cwd=str(directory),
66
+ check=True,
67
+ )
68
+
69
+ if preview:
70
+ click.echo(f"Proposed commit message:\n\n{message}")
71
+ if click.confirm("Create this commit?"):
72
+ git_commit()
73
+ else:
74
+ git_commit()
75
+
76
+
77
+ def has_stage_changes(directory: pathlib.Path) -> bool | Never:
78
+ """Return whether the repository has staged changes.
79
+
80
+ Args:
81
+ directory: Directory of the Git repository to check.
82
+
83
+ Returns:
84
+ True if there are staged changes.
85
+
86
+ Raises:
87
+ NoGitRepoError: If the directory is not inside a Git repository.
88
+ """
89
+ result = subprocess.run(
90
+ ["git", "diff", "--cached", "--quiet"],
91
+ text=True,
92
+ capture_output=True,
93
+ cwd=str(directory),
94
+ )
95
+ if result.returncode == 0:
96
+ return False
97
+ if result.returncode == 1:
98
+ return True
99
+ raise NoGitRepoError(directory=directory)
@@ -0,0 +1,48 @@
1
+ """Prompt used to ask opencode for a Conventional Commit message."""
2
+
3
+ from typing import Final
4
+
5
+ PROMPT: Final[str] = """
6
+ Conventional Commit Generator
7
+
8
+ Act as an experienced software engineer. Analyze the available code changes, diffs,
9
+ staged changes, or modified files and generate one accurate, concise commit message
10
+ following Conventional Commits 1.0.0.
11
+ Rules
12
+
13
+ Determine the primary purpose and use the appropriate type: feat, fix, refactor,
14
+ perf, docs, test, style, build, ci, or chore.
15
+ Add an optional scope when relevant.
16
+ Write the description in English, using imperative mood, lowercase where
17
+ appropriate, and a specific summary.
18
+ Use ! and/or a BREAKING CHANGE: footer only when a breaking change is supported by
19
+ evidence.
20
+ Include a body or footer only when essential and supported by the changes.
21
+ Never invent functionality, intent, issue references, or metadata.
22
+ If changes are unavailable or insufficient, output only a concise request for the
23
+ missing diff or code.
24
+ Do not execute Git commands that create, amend, or apply commits. Do not modify
25
+ files or the repository.
26
+
27
+ Strict Output Requirements
28
+
29
+ Output only the final commit message or the minimal request for missing changes.
30
+ Never output analysis, reasoning, status updates, progress messages,
31
+ acknowledgments, introductions, explanations, or statements about inspecting the
32
+ repository.
33
+ Never describe what you are doing or what you need to do before producing the
34
+ result.
35
+ Never include phrases such as "I need to inspect the current changes in the repo to
36
+ generate the appropriate Conventional Commit message."
37
+ Do not include multiple alternatives, Markdown code fences, or surrounding quotation
38
+ marks.
39
+ Do not generate a commit; suggest the message only.
40
+
41
+ Format
42
+
43
+ <type>[optional scope][optional !]: <description>
44
+
45
+ Optionally add a meaningful body and/or justified footer.
46
+
47
+ Return the commit message directly, with no preceding or trailing text.
48
+ """
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: commit-me
3
+ Version: 0.5.1
4
+ Summary: Generate Conventional Commit messages for your staged changes with opencode and commit them in one step.
5
+ Author-email: Gamaliel Garcia <46827955+EGAMAGZ@users.noreply.github.com>
6
+ Maintainer-email: Gamaliel Garcia <46827955+EGAMAGZ@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/EGAMAGZ/commit-me
9
+ Project-URL: Repository, https://github.com/EGAMAGZ/commit-me
10
+ Keywords: ai,cli,commit,commit-message,conventional-commits,git,llm,opencode
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Version Control :: Git
20
+ Requires-Python: >=3.12
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: click>=8.5.0
24
+ Requires-Dist: opencode-py>=0.8.0
25
+ Requires-Dist: rich>=15.0.0
26
+ Requires-Dist: rich-click>=1.9.9
27
+ Dynamic: license-file
28
+
29
+ # commit-me
30
+
31
+ [![PyPI version](https://img.shields.io/pypi/v/commit-me.svg)](https://pypi.org/project/commit-me/)
32
+ [![PyPI - Python versions](https://img.shields.io/pypi/pyversions/commit-me.svg)](https://pypi.org/project/commit-me/)
33
+ [![PyPI downloads](https://img.shields.io/pypi/dm/commit-me.svg)](https://pypi.org/project/commit-me/)
34
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
35
+ [![CI](https://github.com/EGAMAGZ/commit-me/actions/workflows/ci.yml/badge.svg)](https://github.com/EGAMAGZ/commit-me/actions/workflows/ci.yml)
36
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
37
+
38
+ Generate [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) messages for your staged changes with [opencode](https://opencode.ai) and commit them in one step.
39
+
40
+ `commit-me` inspects the staged diff of a Git repository, asks an opencode session for a single accurate commit message, and creates the commit. It never invents metadata and will not modify files or history.
41
+
42
+ ## Requirements
43
+
44
+ - Python 3.12+
45
+ - [Git](https://git-scm.com/) on your `PATH`
46
+ - [opencode](https://opencode.ai) on your `PATH`
47
+
48
+ ## Install
49
+
50
+ With [uv](https://docs.astral.sh/uv/):
51
+
52
+ ```sh
53
+ uv tool install commit-me
54
+ ```
55
+
56
+ With [pipx](https://pipx.pypa.io/):
57
+
58
+ ```sh
59
+ pipx install commit-me
60
+ ```
61
+
62
+ With pip:
63
+
64
+ ```sh
65
+ pip install commit-me
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ Stage your changes, then run:
71
+
72
+ ```sh
73
+ python -m commit_me.cli
74
+ ```
75
+
76
+ Options:
77
+
78
+ | Option | Description |
79
+ | --- | --- |
80
+ | `-p`, `--preview` | Show the proposed message and ask for confirmation before committing. |
81
+ | `-m`, `--model` | opencode model to use (default: `opencode/big-pickle`). |
82
+ | `DIRECTORY` | Path to the Git repository (default: current directory). |
83
+
84
+ Preview a message without committing:
85
+
86
+ ```sh
87
+ python -m commit_me.cli --preview
88
+ ```
89
+
90
+ ## Development
91
+
92
+ ```sh
93
+ uv sync --group dev
94
+ uv run ty check
95
+ uv run ruff check .
96
+ uv run ruff format --check .
97
+ ```
98
+
99
+ ## License
100
+
101
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ commit_me/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ commit_me/ai.py,sha256=wep8Prcxs1JHyoBNsXFaN1UjYKXxj4nh3hhgzxnVmxo,5589
3
+ commit_me/cli.py,sha256=QeQ8MW9fDLazl36yRnGPHgNF-Kr1h7WTAeCH-FNfUwA,1948
4
+ commit_me/exceptions.py,sha256=dxOlrjn5BxzpEFD9KKrjOetdGm8SFthRLU-bWYC_BUI,1826
5
+ commit_me/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ commit_me/util/command.py,sha256=zNGY9niPQgdm7aB1IUPn8j1uqqDYT-BLcB0ZBEaT2FQ,2606
7
+ commit_me/util/constants.py,sha256=VXOnXSdwt3K-7QUL1V0AGv55r51rg4MauUpbvbPbvoE,2015
8
+ commit_me-0.5.1.dist-info/licenses/LICENSE,sha256=ujS4dTZtnoe3nwvkve8kF0dVU3jXpOWvYJH29DNC5j0,1072
9
+ commit_me-0.5.1.dist-info/METADATA,sha256=DOnwgYrUqLK4PbWadMLN5K5CekJSD0VY-Clo4VL8_n4,3260
10
+ commit_me-0.5.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ commit_me-0.5.1.dist-info/entry_points.txt,sha256=I2iTJdgwEchvxmhZEJCPNFQ9EQNfCtTxd4OZ3_wEsaQ,58
12
+ commit_me-0.5.1.dist-info/top_level.txt,sha256=Mgq7LmSrDzwZO9dFBRNZ9Te6qVnS9Hkl7PZSh4cSlj4,10
13
+ commit_me-0.5.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ commit-me = commit_me.cli:commit_me_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gamaliel Garcia
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 deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
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 FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ commit_me