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 +0 -0
- commit_me/ai.py +199 -0
- commit_me/cli.py +58 -0
- commit_me/exceptions.py +56 -0
- commit_me/util/__init__.py +0 -0
- commit_me/util/command.py +99 -0
- commit_me/util/constants.py +48 -0
- commit_me-0.5.1.dist-info/METADATA +101 -0
- commit_me-0.5.1.dist-info/RECORD +13 -0
- commit_me-0.5.1.dist-info/WHEEL +5 -0
- commit_me-0.5.1.dist-info/entry_points.txt +2 -0
- commit_me-0.5.1.dist-info/licenses/LICENSE +21 -0
- commit_me-0.5.1.dist-info/top_level.txt +1 -0
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()
|
commit_me/exceptions.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/commit-me/)
|
|
32
|
+
[](https://pypi.org/project/commit-me/)
|
|
33
|
+
[](https://pypi.org/project/commit-me/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
[](https://github.com/EGAMAGZ/commit-me/actions/workflows/ci.yml)
|
|
36
|
+
[](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,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
|