diffron 0.1.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.
diffron/commit_gen.py ADDED
@@ -0,0 +1,151 @@
1
+ """
2
+ Commit message generation for Diffron.
3
+
4
+ Generates Conventional Commits format messages from git diffs.
5
+ """
6
+
7
+ import os
8
+ from typing import Optional
9
+
10
+ from .lemonade import LemonadeClient
11
+ from .utils import get_staged_diff
12
+
13
+
14
+ # Conventional Commits types
15
+ COMMIT_TYPES = [
16
+ "feat", # New feature
17
+ "fix", # Bug fix
18
+ "docs", # Documentation changes
19
+ "style", # Code style changes (formatting, etc.)
20
+ "refactor", # Code refactoring
21
+ "perf", # Performance improvements
22
+ "test", # Test additions/modifications
23
+ "build", # Build system/external dependencies
24
+ "ci", # CI configuration
25
+ "chore", # Other changes
26
+ "revert", # Revert previous commit
27
+ ]
28
+
29
+ DEFAULT_MAX_CHARS = 4000
30
+ DEFAULT_MAX_TOKENS = 100
31
+ DEFAULT_TEMPERATURE = 0.2
32
+
33
+
34
+ def generate_commit_message(
35
+ diff: Optional[str] = None,
36
+ max_chars: Optional[int] = None,
37
+ max_tokens: Optional[int] = None,
38
+ temperature: Optional[float] = None,
39
+ client: Optional[LemonadeClient] = None,
40
+ ) -> str:
41
+ """
42
+ Generate a commit message from a git diff.
43
+
44
+ Args:
45
+ diff: Git diff string. If None, gets staged diff automatically.
46
+ max_chars: Maximum characters of diff to send. Defaults to 4000.
47
+ max_tokens: Maximum tokens to generate. Defaults to 100.
48
+ temperature: Sampling temperature. Defaults to 0.2.
49
+ client: LemonadeClient instance. Creates new one if not provided.
50
+
51
+ Returns:
52
+ Generated commit message in Conventional Commits format.
53
+
54
+ Raises:
55
+ ValueError: If diff is empty.
56
+ ConnectionError: If Lemonade is not running.
57
+ """
58
+ max_chars = max_chars or int(os.environ.get("DIFFRON_MAX_DIFF_CHARS", DEFAULT_MAX_CHARS))
59
+ max_tokens = max_tokens or DEFAULT_MAX_TOKENS
60
+ temperature = temperature if temperature is not None else DEFAULT_TEMPERATURE
61
+
62
+ # Get diff if not provided
63
+ if diff is None:
64
+ diff = get_staged_diff(max_chars=max_chars)
65
+
66
+ if not diff.strip():
67
+ raise ValueError("No staged changes to generate commit message from.")
68
+
69
+ # Create client if not provided
70
+ if client is None:
71
+ client = LemonadeClient()
72
+
73
+ # Build prompt
74
+ commit_types = ", ".join(COMMIT_TYPES)
75
+ prompt = (
76
+ f"Write a concise Git commit message in Conventional Commits format. "
77
+ f"Use one of these types: {commit_types}. "
78
+ f"Format: 'type: description' (e.g., 'feat: add user authentication'). "
79
+ f"Output ONLY the commit message, nothing else. No quotes, no explanations.\n\n"
80
+ f"Diff:\n{diff}"
81
+ )
82
+
83
+ messages = [{"role": "user", "content": prompt}]
84
+
85
+ # Generate completion
86
+ response = client.chat_completion(
87
+ messages=messages,
88
+ max_tokens=max_tokens,
89
+ temperature=temperature,
90
+ )
91
+
92
+ # Clean up response
93
+ commit_message = response.strip()
94
+
95
+ # Remove any surrounding quotes
96
+ if commit_message.startswith('"') and commit_message.endswith('"'):
97
+ commit_message = commit_message[1:-1]
98
+ if commit_message.startswith("'") and commit_message.endswith("'"):
99
+ commit_message = commit_message[1:-1]
100
+
101
+ # Remove markdown code blocks if present
102
+ if commit_message.startswith("```"):
103
+ lines = commit_message.split("\n")
104
+ commit_message = "\n".join(
105
+ line for line in lines
106
+ if not line.startswith("```")
107
+ ).strip()
108
+
109
+ return commit_message
110
+
111
+
112
+ def validate_commit_type(message: str) -> bool:
113
+ """
114
+ Validate that a commit message starts with a valid Conventional Commits type.
115
+
116
+ Args:
117
+ message: Commit message to validate.
118
+
119
+ Returns:
120
+ True if valid, False otherwise.
121
+ """
122
+ for commit_type in COMMIT_TYPES:
123
+ if message.lower().startswith(f"{commit_type}:"):
124
+ return True
125
+ if message.lower().startswith(f"{commit_type}("): # With scope
126
+ return True
127
+ return False
128
+
129
+
130
+ def format_commit_message(
131
+ commit_type: str,
132
+ description: str,
133
+ scope: Optional[str] = None,
134
+ breaking: bool = False,
135
+ ) -> str:
136
+ """
137
+ Format a commit message with proper Conventional Commits structure.
138
+
139
+ Args:
140
+ commit_type: Type of commit (feat, fix, etc.).
141
+ description: Short description of the change.
142
+ scope: Optional scope (e.g., 'api', 'ui').
143
+ breaking: Whether this is a breaking change.
144
+
145
+ Returns:
146
+ Formatted commit message.
147
+ """
148
+ scope_part = f"({scope})" if scope else ""
149
+ breaking_part = "!" if breaking else ""
150
+
151
+ return f"{commit_type}{scope_part}{breaking_part}: {description}"
diffron/commit_gen.pyi ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ Commit message generation for Diffron.
3
+
4
+ Generates Conventional Commits format messages from git diffs.
5
+ """
6
+
7
+ from typing import Optional
8
+ from .lemonade import LemonadeClient
9
+
10
+
11
+ COMMIT_TYPES: list[str]
12
+
13
+ DEFAULT_MAX_CHARS: int
14
+ DEFAULT_MAX_TOKENS: int
15
+ DEFAULT_TEMPERATURE: float
16
+
17
+
18
+ def generate_commit_message(
19
+ diff: Optional[str] = None,
20
+ max_chars: Optional[int] = None,
21
+ max_tokens: Optional[int] = None,
22
+ temperature: Optional[float] = None,
23
+ client: Optional[LemonadeClient] = None,
24
+ ) -> str:
25
+ """Generate a commit message from a git diff."""
26
+ ...
27
+
28
+
29
+ def validate_commit_type(message: str) -> bool:
30
+ """Validate that a commit message starts with a valid Conventional Commits type."""
31
+ ...
32
+
33
+
34
+ def format_commit_message(
35
+ commit_type: str,
36
+ description: str,
37
+ scope: Optional[str] = None,
38
+ breaking: bool = False,
39
+ ) -> str:
40
+ """Format a commit message with proper Conventional Commits structure."""
41
+ ...
diffron/git_hooks.py ADDED
@@ -0,0 +1,292 @@
1
+ """
2
+ Git hooks installation and management for Diffron.
3
+
4
+ Provides functions to install, uninstall, and verify Diffron Git hooks.
5
+ """
6
+
7
+ import os
8
+ import shutil
9
+ import stat
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from .utils import get_git_dir, is_git_repo
14
+
15
+
16
+ # Hook file names
17
+ WRAPPER_NAME = "prepare-commit-msg"
18
+ PYTHON_HOOK_NAME = "prepare-commit-msg.py"
19
+
20
+ # Get the hooks directory (where the template hooks are stored)
21
+ DIFFRON_PACKAGE_DIR = Path(__file__).parent
22
+ HOOKS_TEMPLATE_DIR = DIFFRON_PACKAGE_DIR.parent / "hooks"
23
+
24
+
25
+ def install_hooks(
26
+ repo_path: str = ".",
27
+ global_install: bool = False,
28
+ ) -> bool:
29
+ """
30
+ Install Diffron Git hooks to a repository.
31
+
32
+ Args:
33
+ repo_path: Path to the git repository. Defaults to current directory.
34
+ global_install: If True, install hooks globally for all repositories.
35
+
36
+ Returns:
37
+ True if installation was successful, False otherwise.
38
+ """
39
+ if global_install:
40
+ return _install_global_hooks()
41
+
42
+ if not is_git_repo(repo_path):
43
+ raise ValueError(f"Not a git repository: {repo_path}")
44
+
45
+ git_dir = get_git_dir(repo_path)
46
+ if git_dir is None:
47
+ raise ValueError(f"Could not find .git directory in: {repo_path}")
48
+
49
+ hooks_dir = Path(git_dir) / "hooks"
50
+ hooks_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ # Copy wrapper script
53
+ wrapper_src = HOOKS_TEMPLATE_DIR / WRAPPER_NAME
54
+ wrapper_dst = hooks_dir / WRAPPER_NAME
55
+
56
+ if wrapper_src.exists():
57
+ shutil.copy2(wrapper_src, wrapper_dst)
58
+ _make_executable(wrapper_dst)
59
+
60
+ # Copy Python hook script
61
+ python_hook_src = HOOKS_TEMPLATE_DIR / PYTHON_HOOK_NAME
62
+ python_hook_dst = hooks_dir / PYTHON_HOOK_NAME
63
+
64
+ if python_hook_src.exists():
65
+ shutil.copy2(python_hook_src, python_hook_dst)
66
+ _make_executable(python_hook_dst)
67
+
68
+ return True
69
+
70
+
71
+ def _install_global_hooks() -> bool:
72
+ """
73
+ Install hooks globally using git config core.hooksPath.
74
+
75
+ Returns:
76
+ True if installation was successful, False otherwise.
77
+ """
78
+ import subprocess
79
+
80
+ # Create global hooks directory in user's home
81
+ home_dir = Path.home()
82
+ global_hooks_dir = home_dir / ".diffron-hooks"
83
+ global_hooks_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ # Copy hook files
86
+ wrapper_src = HOOKS_TEMPLATE_DIR / WRAPPER_NAME
87
+
88
+ # For global install, use the simplified hook that imports from installed package
89
+ python_hook_src = HOOKS_TEMPLATE_DIR / "prepare-commit-msg-global.py"
90
+ if not python_hook_src.exists():
91
+ # Fallback to regular hook if global version doesn't exist
92
+ python_hook_src = HOOKS_TEMPLATE_DIR / PYTHON_HOOK_NAME
93
+
94
+ wrapper_dst = global_hooks_dir / WRAPPER_NAME
95
+ python_hook_dst = global_hooks_dir / PYTHON_HOOK_NAME
96
+
97
+ if wrapper_src.exists():
98
+ shutil.copy2(wrapper_src, wrapper_dst)
99
+ _make_executable(wrapper_dst)
100
+
101
+ if python_hook_src.exists():
102
+ shutil.copy2(python_hook_src, python_hook_dst)
103
+ _make_executable(python_hook_dst)
104
+
105
+ # Configure git to use global hooks path
106
+ hooks_path_str = str(global_hooks_dir).replace("\\", "/")
107
+ try:
108
+ subprocess.run(
109
+ ["git", "config", "--global", "core.hooksPath", hooks_path_str],
110
+ check=True,
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=10,
114
+ )
115
+ return True
116
+ except subprocess.SubprocessError:
117
+ return False
118
+
119
+
120
+ def uninstall_hooks(
121
+ repo_path: str = ".",
122
+ global_install: bool = False,
123
+ ) -> bool:
124
+ """
125
+ Remove Diffron Git hooks from a repository.
126
+
127
+ Args:
128
+ repo_path: Path to the git repository. Defaults to current directory.
129
+ global_install: If True, remove global hooks configuration.
130
+
131
+ Returns:
132
+ True if uninstallation was successful, False otherwise.
133
+ """
134
+ import subprocess
135
+
136
+ if global_install:
137
+ # Remove global hooks configuration
138
+ try:
139
+ subprocess.run(
140
+ ["git", "config", "--global", "--unset", "core.hooksPath"],
141
+ check=True,
142
+ capture_output=True,
143
+ text=True,
144
+ timeout=10,
145
+ )
146
+ except subprocess.SubprocessError:
147
+ pass # Config might not be set
148
+
149
+ # Remove global hooks directory
150
+ home_dir = Path.home()
151
+ global_hooks_dir = home_dir / ".diffron-hooks"
152
+ if global_hooks_dir.exists():
153
+ try:
154
+ shutil.rmtree(global_hooks_dir)
155
+ except Exception:
156
+ pass
157
+ return True
158
+
159
+ if not is_git_repo(repo_path):
160
+ raise ValueError(f"Not a git repository: {repo_path}")
161
+
162
+ git_dir = get_git_dir(repo_path)
163
+ if git_dir is None:
164
+ raise ValueError(f"Could not find .git directory in: {repo_path}")
165
+
166
+ hooks_dir = Path(git_dir) / "hooks"
167
+
168
+ # Remove hook files
169
+ wrapper_path = hooks_dir / WRAPPER_NAME
170
+ python_hook_path = hooks_dir / PYTHON_HOOK_NAME
171
+
172
+ removed = False
173
+
174
+ if wrapper_path.exists():
175
+ wrapper_path.unlink()
176
+ removed = True
177
+
178
+ if python_hook_path.exists():
179
+ python_hook_path.unlink()
180
+ removed = True
181
+
182
+ return removed
183
+
184
+
185
+ def is_hooks_installed(
186
+ repo_path: str = ".",
187
+ check_global: bool = False,
188
+ ) -> bool:
189
+ """
190
+ Check if Diffron hooks are installed.
191
+
192
+ Args:
193
+ repo_path: Path to the git repository. Defaults to current directory.
194
+ check_global: If True, check for global hooks configuration.
195
+
196
+ Returns:
197
+ True if hooks are installed, False otherwise.
198
+ """
199
+ import subprocess
200
+
201
+ if check_global:
202
+ # Check global hooks configuration
203
+ try:
204
+ result = subprocess.run(
205
+ ["git", "config", "--global", "core.hooksPath"],
206
+ capture_output=True,
207
+ text=True,
208
+ timeout=10,
209
+ )
210
+ if result.returncode == 0 and result.stdout.strip():
211
+ hooks_path = result.stdout.strip()
212
+ # Check if it's the diffron hooks directory
213
+ if ".diffron-hooks" in hooks_path:
214
+ return True
215
+ except subprocess.SubprocessError:
216
+ pass
217
+ return False
218
+
219
+ if not is_git_repo(repo_path):
220
+ return False
221
+
222
+ git_dir = get_git_dir(repo_path)
223
+ if git_dir is None:
224
+ return False
225
+
226
+ hooks_dir = Path(git_dir) / "hooks"
227
+
228
+ # Check if both hook files exist
229
+ wrapper_path = hooks_dir / WRAPPER_NAME
230
+ python_hook_path = hooks_dir / PYTHON_HOOK_NAME
231
+
232
+ return wrapper_path.exists() and python_hook_path.exists()
233
+
234
+
235
+ def _make_executable(path: Path) -> None:
236
+ """
237
+ Make a file executable.
238
+
239
+ Args:
240
+ path: Path to the file.
241
+ """
242
+ if os.name == "nt":
243
+ # Windows doesn't use Unix permissions, but we can still set the flag
244
+ pass
245
+ else:
246
+ # Unix-like systems
247
+ current_mode = path.stat().st_mode
248
+ path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
249
+
250
+
251
+ def get_hooks_status(repo_path: str = ".") -> dict:
252
+ """
253
+ Get detailed status of hooks installation.
254
+
255
+ Args:
256
+ repo_path: Path to the git repository.
257
+
258
+ Returns:
259
+ Dict with status information.
260
+ """
261
+ status = {
262
+ "is_git_repo": is_git_repo(repo_path),
263
+ "local_hooks_installed": False,
264
+ "global_hooks_configured": False,
265
+ "wrapper_exists": False,
266
+ "python_hook_exists": False,
267
+ "git_dir": None,
268
+ "hooks_dir": None,
269
+ }
270
+
271
+ if status["is_git_repo"]:
272
+ git_dir = get_git_dir(repo_path)
273
+ status["git_dir"] = str(git_dir) if git_dir else None
274
+
275
+ if git_dir:
276
+ hooks_dir = Path(git_dir) / "hooks"
277
+ status["hooks_dir"] = str(hooks_dir)
278
+
279
+ wrapper_path = hooks_dir / WRAPPER_NAME
280
+ python_hook_path = hooks_dir / PYTHON_HOOK_NAME
281
+
282
+ status["wrapper_exists"] = wrapper_path.exists()
283
+ status["python_hook_exists"] = python_hook_path.exists()
284
+ status["local_hooks_installed"] = (
285
+ status["wrapper_exists"] and status["python_hook_exists"]
286
+ )
287
+
288
+ status["global_hooks_configured"] = is_hooks_installed(
289
+ repo_path, check_global=True
290
+ )
291
+
292
+ return status
diffron/git_hooks.pyi ADDED
@@ -0,0 +1,52 @@
1
+ """
2
+ Git hooks installation and management for Diffron.
3
+
4
+ Provides functions to install, uninstall, and verify Diffron Git hooks.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ WRAPPER_NAME: str
12
+ PYTHON_HOOK_NAME: str
13
+ HOOKS_TEMPLATE_DIR: Path
14
+
15
+
16
+ def install_hooks(
17
+ repo_path: str = ".",
18
+ global_install: bool = False,
19
+ ) -> bool:
20
+ """Install Diffron Git hooks to a repository."""
21
+ ...
22
+
23
+
24
+ def _install_global_hooks() -> bool:
25
+ """Install hooks globally using git config core.hooksPath."""
26
+ ...
27
+
28
+
29
+ def uninstall_hooks(
30
+ repo_path: str = ".",
31
+ global_install: bool = False,
32
+ ) -> bool:
33
+ """Remove Diffron Git hooks from a repository."""
34
+ ...
35
+
36
+
37
+ def is_hooks_installed(
38
+ repo_path: str = ".",
39
+ check_global: bool = False,
40
+ ) -> bool:
41
+ """Check if Diffron hooks are installed."""
42
+ ...
43
+
44
+
45
+ def _make_executable(path: Path) -> None:
46
+ """Make a file executable."""
47
+ ...
48
+
49
+
50
+ def get_hooks_status(repo_path: str = ".") -> dict:
51
+ """Get detailed status of hooks installation."""
52
+ ...