cmt-cli 0.1.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.
- cmt_cli-0.1.0/.gitignore +13 -0
- cmt_cli-0.1.0/.python-version +1 -0
- cmt_cli-0.1.0/LICENSE +21 -0
- cmt_cli-0.1.0/PKG-INFO +21 -0
- cmt_cli-0.1.0/README.md +7 -0
- cmt_cli-0.1.0/pyproject.toml +50 -0
- cmt_cli-0.1.0/src/cmt/__init__.py +0 -0
- cmt_cli-0.1.0/src/cmt/ai/__init__.py +0 -0
- cmt_cli-0.1.0/src/cmt/ai/cache.py +28 -0
- cmt_cli-0.1.0/src/cmt/ai/openai.py +86 -0
- cmt_cli-0.1.0/src/cmt/ai/prompt.py +66 -0
- cmt_cli-0.1.0/src/cmt/ai/provider.py +14 -0
- cmt_cli-0.1.0/src/cmt/analysis/__init__.py +0 -0
- cmt_cli-0.1.0/src/cmt/analysis/analyzer.py +29 -0
- cmt_cli-0.1.0/src/cmt/cli.py +85 -0
- cmt_cli-0.1.0/src/cmt/config/__init__.py +0 -0
- cmt_cli-0.1.0/src/cmt/config/settings.py +58 -0
- cmt_cli-0.1.0/src/cmt/exceptions.py +14 -0
- cmt_cli-0.1.0/src/cmt/git/__init__.py +0 -0
- cmt_cli-0.1.0/src/cmt/git/repository.py +50 -0
- cmt_cli-0.1.0/src/cmt/models/__init__.py +0 -0
- cmt_cli-0.1.0/src/cmt/models/changes.py +17 -0
- cmt_cli-0.1.0/src/cmt/models/suggestion.py +6 -0
- cmt_cli-0.1.0/src/cmt/utils.py +14 -0
- cmt_cli-0.1.0/tests/__init__.py +0 -0
- cmt_cli-0.1.0/uv.lock +1456 -0
cmt_cli-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
cmt_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bijay Das
|
|
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.
|
cmt_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cmt-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered Git commit message generator
|
|
5
|
+
Author-email: Bijay Das <me@bijaydas.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: langchain-openai>=1.6.0
|
|
10
|
+
Requires-Dist: langchain>=1.3.18
|
|
11
|
+
Requires-Dist: pydantic>=2.13.4
|
|
12
|
+
Requires-Dist: typer>=0.27.1
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# cmt
|
|
16
|
+
|
|
17
|
+
`cmt` is a CLI tool that checks the currently staged Git changes and suggests a professional commit message.
|
|
18
|
+
|
|
19
|
+
## License
|
|
20
|
+
|
|
21
|
+
MIT
|
cmt_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cmt-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AI-powered Git commit message generator"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Bijay Das", email = "me@bijaydas.com" },
|
|
10
|
+
]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"langchain>=1.3.18",
|
|
13
|
+
"langchain-openai>=1.6.0",
|
|
14
|
+
"pydantic>=2.13.4",
|
|
15
|
+
"typer>=0.27.1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
cmt = "cmt.cli:app"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
keywords = ["ai", "git", "commit", "message", "generator"]
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"ruff>=0.16.5",
|
|
29
|
+
]
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
target-version = "py312"
|
|
33
|
+
src = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = [
|
|
37
|
+
"E", # pycodestyle errors
|
|
38
|
+
"F", # pyflakes
|
|
39
|
+
"I", # isort (import sorting)
|
|
40
|
+
"UP", # pyupgrade (modern syntax)
|
|
41
|
+
"B", # flake8-bugbear (common bugs)
|
|
42
|
+
"SIM", # flake8-simplify
|
|
43
|
+
]
|
|
44
|
+
ignore = []
|
|
45
|
+
|
|
46
|
+
[tool.ruff.format]
|
|
47
|
+
quote-style = "double"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
include = ["src/cmt"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from cmt.config.settings import Settings
|
|
6
|
+
from cmt.models.suggestion import CommitSuggestion
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommitMessageCache:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.cache_dir = Path(Settings().CACHE_DIR)
|
|
12
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def _key(diff: str, model: str) -> str:
|
|
16
|
+
return hashlib.sha256(f"{diff}-{model}".encode()).hexdigest()
|
|
17
|
+
|
|
18
|
+
def set(self, diff: str, model: str, commit: CommitSuggestion):
|
|
19
|
+
key = CommitMessageCache._key(diff, model)
|
|
20
|
+
cache_file = self.cache_dir / key
|
|
21
|
+
cache_file.write_text(commit.model_dump_json())
|
|
22
|
+
|
|
23
|
+
def get(self, diff: str, model: str) -> CommitSuggestion | None:
|
|
24
|
+
key = CommitMessageCache._key(diff, model)
|
|
25
|
+
cache_file = self.cache_dir / key
|
|
26
|
+
if cache_file.exists():
|
|
27
|
+
return CommitSuggestion(**json.loads(cache_file.read_text()))
|
|
28
|
+
return None
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from langchain.agents import create_agent
|
|
2
|
+
from langchain_openai import ChatOpenAI
|
|
3
|
+
from pydantic import SecretStr
|
|
4
|
+
|
|
5
|
+
from cmt.ai.cache import CommitMessageCache
|
|
6
|
+
from cmt.ai.prompt import COMMIT_PROMPT, COMMIT_SYSTEM_PROMPT
|
|
7
|
+
from cmt.ai.provider import AIProvider
|
|
8
|
+
from cmt.config.settings import Settings
|
|
9
|
+
from cmt.models.changes import AnalysisResult, StagedChangeSet, StagedFile
|
|
10
|
+
from cmt.models.suggestion import CommitSuggestion
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenAIProvider(AIProvider):
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
settings = Settings()
|
|
16
|
+
self.config = settings.get()
|
|
17
|
+
|
|
18
|
+
def _build_prompt(
|
|
19
|
+
self,
|
|
20
|
+
changes: StagedChangeSet,
|
|
21
|
+
analysis: AnalysisResult
|
|
22
|
+
) -> str:
|
|
23
|
+
staged_files = self._process_files(changes.files)
|
|
24
|
+
staged_diffs = changes.diff
|
|
25
|
+
|
|
26
|
+
return COMMIT_PROMPT.format_messages(
|
|
27
|
+
total_files=len(changes.files),
|
|
28
|
+
added_files=analysis.added_files,
|
|
29
|
+
modified_files=analysis.modified_files,
|
|
30
|
+
deleted_files=analysis.deleted_files,
|
|
31
|
+
renamed_files=analysis.renamed_files,
|
|
32
|
+
staged_files=staged_files,
|
|
33
|
+
staged_diffs=staged_diffs
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _process_files(files: list[StagedFile]) -> str:
|
|
38
|
+
output = ""
|
|
39
|
+
for file in files:
|
|
40
|
+
output += f"{file.status} {file.path}\n"
|
|
41
|
+
|
|
42
|
+
return output.strip()
|
|
43
|
+
|
|
44
|
+
def _invoke(self, prompt: str) -> CommitSuggestion:
|
|
45
|
+
model = ChatOpenAI(
|
|
46
|
+
model=self.config.model,
|
|
47
|
+
api_key=SecretStr(self.config.api_key),
|
|
48
|
+
timeout=30,
|
|
49
|
+
max_tokens=1200,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
agent = create_agent(
|
|
53
|
+
model=model,
|
|
54
|
+
system_prompt=COMMIT_SYSTEM_PROMPT,
|
|
55
|
+
response_format=CommitSuggestion,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
result = agent.invoke({"messages": prompt})
|
|
59
|
+
|
|
60
|
+
return result["structured_response"]
|
|
61
|
+
|
|
62
|
+
def generate_commit_message(
|
|
63
|
+
self,
|
|
64
|
+
change_set: StagedChangeSet,
|
|
65
|
+
analysis: AnalysisResult
|
|
66
|
+
) -> CommitSuggestion:
|
|
67
|
+
prompt = self._build_prompt(change_set, analysis)
|
|
68
|
+
|
|
69
|
+
cache = CommitMessageCache()
|
|
70
|
+
cached_message = cache.get(change_set.diff, self.config.model)
|
|
71
|
+
|
|
72
|
+
if cached_message:
|
|
73
|
+
return CommitSuggestion(
|
|
74
|
+
message=cached_message.message,
|
|
75
|
+
description=cached_message.description
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
suggestion = self._invoke(prompt)
|
|
79
|
+
|
|
80
|
+
cache.set(change_set.diff, self.config.model, suggestion)
|
|
81
|
+
|
|
82
|
+
return suggestion
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def commit_command(commit_suggestion: CommitSuggestion) -> str:
|
|
86
|
+
return f"{commit_suggestion.message} \n\n{commit_suggestion.description}"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
2
|
+
|
|
3
|
+
COMMIT_SYSTEM_PROMPT = """You are an expert software engineer responsible for generating Git commit
|
|
4
|
+
messages.
|
|
5
|
+
|
|
6
|
+
Your task is to analyze the staged Git changes provided below and generate the most appropriate
|
|
7
|
+
commit message.
|
|
8
|
+
|
|
9
|
+
## Rules
|
|
10
|
+
|
|
11
|
+
1. Base the commit message ONLY on the provided staged changes.
|
|
12
|
+
2. Do not invent functionality, behavior, or intent.
|
|
13
|
+
3. Identify the primary purpose of the changes.
|
|
14
|
+
4. If multiple files are changed, determine whether they represent one coherent change.
|
|
15
|
+
5. If the changes are unrelated or miscellaneous, use an appropriate general commit type
|
|
16
|
+
such as `chore`.
|
|
17
|
+
6. Follow the Conventional Commits specification.
|
|
18
|
+
7. Use one of these commit types when appropriate:
|
|
19
|
+
- feat
|
|
20
|
+
- fix
|
|
21
|
+
- refactor
|
|
22
|
+
- docs
|
|
23
|
+
- test
|
|
24
|
+
- chore
|
|
25
|
+
- perf
|
|
26
|
+
- build
|
|
27
|
+
- ci
|
|
28
|
+
- style
|
|
29
|
+
8. Add a scope only when the affected area is clear.
|
|
30
|
+
9. Keep the commit message concise.
|
|
31
|
+
10. Use imperative mood.
|
|
32
|
+
11. Do not mention individual files unless necessary.
|
|
33
|
+
12. Do not generate multiple commit messages.
|
|
34
|
+
13. Do not include a commit body.
|
|
35
|
+
14. Return ONLY the commit message.
|
|
36
|
+
15. Do not include markdown, quotes, explanations, or additional text.
|
|
37
|
+
|
|
38
|
+
## Commit message format
|
|
39
|
+
|
|
40
|
+
<type>[optional scope]: <short description>
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
COMMIT_DATA_PROMPT = """## Change statistics
|
|
44
|
+
|
|
45
|
+
Total files: {total_files}
|
|
46
|
+
Added: {added_files}
|
|
47
|
+
Modified: {modified_files}
|
|
48
|
+
Deleted: {deleted_files}
|
|
49
|
+
Renamed: {renamed_files}
|
|
50
|
+
|
|
51
|
+
## Staged files
|
|
52
|
+
|
|
53
|
+
{staged_files}
|
|
54
|
+
|
|
55
|
+
## Staged Git diff
|
|
56
|
+
|
|
57
|
+
{staged_diffs}
|
|
58
|
+
|
|
59
|
+
Generate the commit message now.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
COMMIT_PROMPT = ChatPromptTemplate.from_messages(
|
|
63
|
+
[
|
|
64
|
+
("human", COMMIT_DATA_PROMPT),
|
|
65
|
+
]
|
|
66
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from cmt.models.changes import AnalysisResult, StagedChangeSet
|
|
4
|
+
from cmt.models.suggestion import CommitSuggestion
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AIProvider(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def generate_commit_message(
|
|
10
|
+
self,
|
|
11
|
+
change_set: StagedChangeSet,
|
|
12
|
+
analysis: AnalysisResult
|
|
13
|
+
) -> CommitSuggestion:
|
|
14
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from cmt.models.changes import AnalysisResult, StagedChangeSet
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Analyzer:
|
|
5
|
+
def analyze(self, change_set: StagedChangeSet) -> AnalysisResult:
|
|
6
|
+
added = 0
|
|
7
|
+
modified = 0
|
|
8
|
+
deleted = 0
|
|
9
|
+
renamed = 0
|
|
10
|
+
total = 0
|
|
11
|
+
|
|
12
|
+
for file in change_set.files:
|
|
13
|
+
total += 1
|
|
14
|
+
if file.status == "A":
|
|
15
|
+
added += 1
|
|
16
|
+
elif file.status == "M":
|
|
17
|
+
modified += 1
|
|
18
|
+
elif file.status == "D":
|
|
19
|
+
deleted += 1
|
|
20
|
+
elif file.status == "R":
|
|
21
|
+
renamed += 1
|
|
22
|
+
|
|
23
|
+
return AnalysisResult(
|
|
24
|
+
total_files=total,
|
|
25
|
+
added_files=added,
|
|
26
|
+
modified_files=modified,
|
|
27
|
+
deleted_files=deleted,
|
|
28
|
+
renamed_files=renamed,
|
|
29
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from cmt.ai.openai import OpenAIProvider
|
|
4
|
+
from cmt.analysis.analyzer import Analyzer
|
|
5
|
+
from cmt.config.settings import OpenAIConfig, Settings
|
|
6
|
+
from cmt.exceptions import CmtError
|
|
7
|
+
from cmt.git.repository import Repository
|
|
8
|
+
from cmt.utils import edit_with_vim
|
|
9
|
+
|
|
10
|
+
app = typer.Typer()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def suggest() -> None:
|
|
15
|
+
try:
|
|
16
|
+
repository = Repository()
|
|
17
|
+
analyzer = Analyzer()
|
|
18
|
+
|
|
19
|
+
if not repository.is_git_repository():
|
|
20
|
+
raise CmtError("Not a git repository.")
|
|
21
|
+
|
|
22
|
+
staged_files = repository.get_staged_changes()
|
|
23
|
+
|
|
24
|
+
if not staged_files.files:
|
|
25
|
+
typer.echo(
|
|
26
|
+
"No staged files found. Please stage your changes before running this command."
|
|
27
|
+
)
|
|
28
|
+
raise typer.Exit(code=0)
|
|
29
|
+
|
|
30
|
+
analysis_result = analyzer.analyze(staged_files)
|
|
31
|
+
|
|
32
|
+
open_ai = OpenAIProvider()
|
|
33
|
+
|
|
34
|
+
commit = open_ai.generate_commit_message(staged_files, analysis_result)
|
|
35
|
+
commit_command = OpenAIProvider.commit_command(commit)
|
|
36
|
+
|
|
37
|
+
typer.echo(f"Suggested commit:\n\n{commit_command}")
|
|
38
|
+
|
|
39
|
+
while True:
|
|
40
|
+
action = (
|
|
41
|
+
typer.prompt("\nUse this message? [y]es / [e]dit / [n]o", default="y")
|
|
42
|
+
.strip()
|
|
43
|
+
.lower()[0]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if action == "n":
|
|
47
|
+
typer.echo("Aborted.")
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
if action == "e":
|
|
51
|
+
commit_command = edit_with_vim(commit_command)
|
|
52
|
+
typer.echo(f"Edited commit:\n\n{commit_command}")
|
|
53
|
+
|
|
54
|
+
if action == "y":
|
|
55
|
+
result = repository.commit(commit_command)
|
|
56
|
+
typer.echo(f"Commit result:\n\n{result.stdout}")
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
except CmtError as e:
|
|
60
|
+
typer.echo(f"Error: {e}", err=True)
|
|
61
|
+
raise typer.Exit(code=1) from None
|
|
62
|
+
except typer.Exit:
|
|
63
|
+
raise
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
typer.echo("\nOperation cancelled by user.", err=True)
|
|
66
|
+
raise typer.Exit(code=1) from None
|
|
67
|
+
except Exception as e:
|
|
68
|
+
typer.echo(f"Unexpected error: {e}", err=True)
|
|
69
|
+
raise typer.Exit(code=1) from None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def config(task: str) -> None:
|
|
74
|
+
if task == "set":
|
|
75
|
+
settings = Settings()
|
|
76
|
+
|
|
77
|
+
open_ai_key = typer.prompt("Enter your OpenAI API key")
|
|
78
|
+
open_ai_model = typer.prompt(
|
|
79
|
+
"Enter your OpenAI model", default=settings.OPEN_AI_DEFAULT_MODEL
|
|
80
|
+
)
|
|
81
|
+
settings.set(OpenAIConfig(api_key=open_ai_key, model=open_ai_model))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from configparser import ConfigParser
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from cmt.exceptions import ConfigurationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenAIConfig(BaseModel):
|
|
10
|
+
api_key: str
|
|
11
|
+
model: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Settings:
|
|
15
|
+
CONFIG_DIR: Path = Path.home() / ".config" / "cmt"
|
|
16
|
+
CACHE_DIR: Path = CONFIG_DIR / "cache"
|
|
17
|
+
|
|
18
|
+
CONFIG_FILE: Path = CONFIG_DIR / "config.ini"
|
|
19
|
+
OPEN_AI_DEFAULT_MODEL: str = "gpt-4o-mini"
|
|
20
|
+
|
|
21
|
+
def set(self, openai_config: OpenAIConfig):
|
|
22
|
+
self.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
config = ConfigParser()
|
|
25
|
+
config["openai"] = {
|
|
26
|
+
"api_key": openai_config.api_key,
|
|
27
|
+
"model": openai_config.model
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
with open(self.CONFIG_FILE, "w") as f:
|
|
31
|
+
config.write(f)
|
|
32
|
+
|
|
33
|
+
def get(self) -> OpenAIConfig:
|
|
34
|
+
if not self.CONFIG_FILE.exists():
|
|
35
|
+
raise FileNotFoundError(
|
|
36
|
+
"Configuration file not found. Run `cmt config set` to set it up."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
config = ConfigParser()
|
|
40
|
+
config.read(self.CONFIG_FILE)
|
|
41
|
+
|
|
42
|
+
api_key = config["openai"].get("api_key")
|
|
43
|
+
model = config["openai"].get("model")
|
|
44
|
+
|
|
45
|
+
if not api_key:
|
|
46
|
+
raise ConfigurationError(
|
|
47
|
+
"API key not found in the configuration. Run `cmt config set` to set it up."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if not model:
|
|
51
|
+
raise ConfigurationError(
|
|
52
|
+
"Model not found in the configuration. Run `cmt config set` to set it up."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return OpenAIConfig(
|
|
56
|
+
api_key=api_key,
|
|
57
|
+
model=model
|
|
58
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CmtError(Exception):
|
|
2
|
+
"""Base exception for expected cmt errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigurationError(CmtError):
|
|
6
|
+
"""Raised when cmt configuration is missing or invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitError(CmtError):
|
|
10
|
+
"""Raised when a Git operation fails."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AIError(CmtError):
|
|
14
|
+
"""Raised when AI processing fails."""
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from cmt.models.changes import StagedChangeSet, StagedFile
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Repository:
|
|
8
|
+
def __init__(self, root: Path | None = None):
|
|
9
|
+
self.root = Path(root) if root is not None else Path.cwd()
|
|
10
|
+
|
|
11
|
+
def _execute(self, command: list[str]) -> subprocess.CompletedProcess:
|
|
12
|
+
return subprocess.run(
|
|
13
|
+
command,
|
|
14
|
+
cwd=self.root,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
check=True,
|
|
17
|
+
text=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def is_git_repository(self):
|
|
21
|
+
try:
|
|
22
|
+
result = self._execute(["git", "rev-parse", "--is-inside-work-tree"])
|
|
23
|
+
return result.stdout.strip() == "true"
|
|
24
|
+
except subprocess.CalledProcessError:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
def _get_staged_files(self) -> list[StagedFile]:
|
|
28
|
+
result = self._execute(["git", "diff", "--name-status", "--cached"])
|
|
29
|
+
|
|
30
|
+
files: list[StagedFile] = []
|
|
31
|
+
|
|
32
|
+
for line in result.stdout.splitlines():
|
|
33
|
+
_line = line.split("\t", 1)
|
|
34
|
+
status, path = _line
|
|
35
|
+
files.append(StagedFile(status=status, path=path))
|
|
36
|
+
|
|
37
|
+
return files
|
|
38
|
+
|
|
39
|
+
def _get_staged_diff(self) -> str:
|
|
40
|
+
result = self._execute(["git", "diff", "--cached"])
|
|
41
|
+
return result.stdout
|
|
42
|
+
|
|
43
|
+
def get_staged_changes(self) -> StagedChangeSet:
|
|
44
|
+
return StagedChangeSet(
|
|
45
|
+
files=self._get_staged_files(),
|
|
46
|
+
diff=self._get_staged_diff()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def commit(self, message: str) -> subprocess.CompletedProcess:
|
|
50
|
+
return self._execute(["git", "commit", "-m", message])
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StagedFile(BaseModel):
|
|
5
|
+
status: str
|
|
6
|
+
path: str
|
|
7
|
+
|
|
8
|
+
class StagedChangeSet(BaseModel):
|
|
9
|
+
files: list[StagedFile]
|
|
10
|
+
diff: str
|
|
11
|
+
|
|
12
|
+
class AnalysisResult(BaseModel):
|
|
13
|
+
total_files: int
|
|
14
|
+
added_files: int
|
|
15
|
+
modified_files: int
|
|
16
|
+
deleted_files: int
|
|
17
|
+
renamed_files: int
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import tempfile
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def edit_with_vim(message: str) -> str:
|
|
6
|
+
with tempfile.NamedTemporaryFile(
|
|
7
|
+
mode="w+",
|
|
8
|
+
delete=False
|
|
9
|
+
) as tmp_file:
|
|
10
|
+
tmp_file.write(message)
|
|
11
|
+
tmp_file.flush()
|
|
12
|
+
subprocess.run(["vim", tmp_file.name], check=True)
|
|
13
|
+
tmp_file.seek(0)
|
|
14
|
+
return tmp_file.read().strip()
|
|
File without changes
|