hunknote 1.0.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.
- hunknote/__init__.py +3 -0
- hunknote/__main__.py +7 -0
- hunknote/cache.py +334 -0
- hunknote/cli.py +828 -0
- hunknote/config.py +170 -0
- hunknote/formatters.py +79 -0
- hunknote/git_ctx.py +352 -0
- hunknote/global_config.py +362 -0
- hunknote/llm/__init__.py +108 -0
- hunknote/llm/anthropic_provider.py +99 -0
- hunknote/llm/base.py +213 -0
- hunknote/llm/cohere_provider.py +103 -0
- hunknote/llm/google_provider.py +198 -0
- hunknote/llm/groq_provider.py +103 -0
- hunknote/llm/mistral_provider.py +103 -0
- hunknote/llm/openai_provider.py +103 -0
- hunknote/llm/openrouter_provider.py +118 -0
- hunknote/user_config.py +216 -0
- hunknote-1.0.0.dist-info/METADATA +490 -0
- hunknote-1.0.0.dist-info/RECORD +23 -0
- hunknote-1.0.0.dist-info/WHEEL +4 -0
- hunknote-1.0.0.dist-info/entry_points.txt +4 -0
- hunknote-1.0.0.dist-info/licenses/LICENSE +22 -0
hunknote/__init__.py
ADDED
hunknote/__main__.py
ADDED
hunknote/cache.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Caching utilities for hunknote to prevent redundant LLM API calls.
|
|
2
|
+
|
|
3
|
+
Backward compatibility:
|
|
4
|
+
- Falls back to .aicommit/ if .hunknote/ doesn't exist
|
|
5
|
+
- Warns users about deprecated paths
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CacheMetadata(BaseModel):
|
|
19
|
+
"""Metadata stored alongside the cached commit message."""
|
|
20
|
+
|
|
21
|
+
context_hash: str
|
|
22
|
+
generated_at: str # ISO format timestamp
|
|
23
|
+
model: str
|
|
24
|
+
input_tokens: int
|
|
25
|
+
output_tokens: int
|
|
26
|
+
staged_files: list[str]
|
|
27
|
+
original_message: str
|
|
28
|
+
diff_preview: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Backward compatibility tracking
|
|
32
|
+
_CACHE_MIGRATION_WARNED = set()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _warn_deprecated_cache_path(repo_root: Path) -> None:
|
|
36
|
+
"""Warn user about deprecated cache path.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
repo_root: The repository root directory.
|
|
40
|
+
"""
|
|
41
|
+
repo_key = str(repo_root.absolute())
|
|
42
|
+
if repo_key not in _CACHE_MIGRATION_WARNED:
|
|
43
|
+
old_path = repo_root / ".aicommit"
|
|
44
|
+
new_path = repo_root / ".hunknote"
|
|
45
|
+
print(f"\n⚠️ WARNING: Using deprecated cache directory", file=sys.stderr)
|
|
46
|
+
print(f" Repo: {repo_root}", file=sys.stderr)
|
|
47
|
+
print(f" Old: {old_path}", file=sys.stderr)
|
|
48
|
+
print(f" New: {new_path}", file=sys.stderr)
|
|
49
|
+
print(f" Run 'hunknote migrate' to update your cache.", file=sys.stderr)
|
|
50
|
+
print(f" (This warning will only show once per repo)\n", file=sys.stderr)
|
|
51
|
+
_CACHE_MIGRATION_WARNED.add(repo_key)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_cache_dir(repo_root: Path) -> Path:
|
|
55
|
+
"""Return the .hunknote directory, creating it if needed.
|
|
56
|
+
|
|
57
|
+
Falls back to .aicommit/ for backward compatibility.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
repo_root: The root directory of the git repository.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Path to the .hunknote cache directory (or .aicommit for backward compatibility).
|
|
64
|
+
"""
|
|
65
|
+
new_dir = repo_root / ".hunknote"
|
|
66
|
+
old_dir = repo_root / ".aicommit"
|
|
67
|
+
|
|
68
|
+
# If new directory exists, use it
|
|
69
|
+
if new_dir.exists():
|
|
70
|
+
return new_dir
|
|
71
|
+
|
|
72
|
+
# If old directory exists but new doesn't, use old and warn
|
|
73
|
+
if old_dir.exists():
|
|
74
|
+
_warn_deprecated_cache_path(repo_root)
|
|
75
|
+
return old_dir
|
|
76
|
+
|
|
77
|
+
# Neither exists, create and return new
|
|
78
|
+
new_dir.mkdir(exist_ok=True)
|
|
79
|
+
return new_dir
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_message_file(repo_root: Path) -> Path:
|
|
83
|
+
"""Return path to the cached message file.
|
|
84
|
+
|
|
85
|
+
Falls back to aicommit_message.txt for backward compatibility.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
repo_root: The root directory of the git repository.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Path to hunknote_message.txt (or aicommit_message.txt for backward compatibility).
|
|
92
|
+
"""
|
|
93
|
+
cache_dir = get_cache_dir(repo_root)
|
|
94
|
+
new_file = cache_dir / "hunknote_message.txt"
|
|
95
|
+
old_file = cache_dir / "aicommit_message.txt"
|
|
96
|
+
|
|
97
|
+
# If new file exists, use it
|
|
98
|
+
if new_file.exists():
|
|
99
|
+
return new_file
|
|
100
|
+
|
|
101
|
+
# If old file exists, use it (will be migrated later)
|
|
102
|
+
if old_file.exists():
|
|
103
|
+
return old_file
|
|
104
|
+
|
|
105
|
+
# Neither exists, return new
|
|
106
|
+
return new_file
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_hash_file(repo_root: Path) -> Path:
|
|
110
|
+
"""Return path to the context hash file.
|
|
111
|
+
|
|
112
|
+
Falls back to aicommit_context_hash.txt for backward compatibility.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
repo_root: The root directory of the git repository.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Path to hunknote_context_hash.txt (or aicommit_context_hash.txt for backward compatibility).
|
|
119
|
+
"""
|
|
120
|
+
cache_dir = get_cache_dir(repo_root)
|
|
121
|
+
new_file = cache_dir / "hunknote_context_hash.txt"
|
|
122
|
+
old_file = cache_dir / "aicommit_context_hash.txt"
|
|
123
|
+
|
|
124
|
+
# If new file exists, use it
|
|
125
|
+
if new_file.exists():
|
|
126
|
+
return new_file
|
|
127
|
+
|
|
128
|
+
# If old file exists, use it (will be migrated later)
|
|
129
|
+
if old_file.exists():
|
|
130
|
+
return old_file
|
|
131
|
+
|
|
132
|
+
# Neither exists, return new
|
|
133
|
+
return new_file
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_metadata_file(repo_root: Path) -> Path:
|
|
137
|
+
"""Return path to the metadata JSON file.
|
|
138
|
+
|
|
139
|
+
Falls back to aicommit_metadata.json for backward compatibility.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
repo_root: The root directory of the git repository.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Path to hunknote_metadata.json (or aicommit_metadata.json for backward compatibility).
|
|
146
|
+
"""
|
|
147
|
+
cache_dir = get_cache_dir(repo_root)
|
|
148
|
+
new_file = cache_dir / "hunknote_metadata.json"
|
|
149
|
+
old_file = cache_dir / "aicommit_metadata.json"
|
|
150
|
+
|
|
151
|
+
# If new file exists, use it
|
|
152
|
+
if new_file.exists():
|
|
153
|
+
return new_file
|
|
154
|
+
|
|
155
|
+
# If old file exists, use it (will be migrated later)
|
|
156
|
+
if old_file.exists():
|
|
157
|
+
return old_file
|
|
158
|
+
|
|
159
|
+
# Neither exists, return new
|
|
160
|
+
return new_file
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def compute_context_hash(context_bundle: str) -> str:
|
|
164
|
+
"""Compute SHA256 hash of the context bundle.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
context_bundle: The full git context string.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
SHA256 hex digest of the context.
|
|
171
|
+
"""
|
|
172
|
+
return hashlib.sha256(context_bundle.encode()).hexdigest()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def is_cache_valid(repo_root: Path, current_hash: str) -> bool:
|
|
176
|
+
"""Check if cached message is still valid for the current context.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
repo_root: The root directory of the git repository.
|
|
180
|
+
current_hash: The hash of the current context bundle.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True if cache is valid, False otherwise.
|
|
184
|
+
"""
|
|
185
|
+
hash_file = get_hash_file(repo_root)
|
|
186
|
+
message_file = get_message_file(repo_root)
|
|
187
|
+
|
|
188
|
+
if not hash_file.exists() or not message_file.exists():
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
stored_hash = hash_file.read_text().strip()
|
|
192
|
+
return stored_hash == current_hash
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def save_cache(
|
|
196
|
+
repo_root: Path,
|
|
197
|
+
context_hash: str,
|
|
198
|
+
message: str,
|
|
199
|
+
model: str,
|
|
200
|
+
input_tokens: int,
|
|
201
|
+
output_tokens: int,
|
|
202
|
+
staged_files: list[str],
|
|
203
|
+
diff_preview: str,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Save the generated message and its metadata to cache.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
repo_root: The root directory of the git repository.
|
|
209
|
+
context_hash: SHA256 hash of the context bundle.
|
|
210
|
+
message: The rendered commit message.
|
|
211
|
+
model: The LLM model used for generation.
|
|
212
|
+
input_tokens: Number of input tokens used.
|
|
213
|
+
output_tokens: Number of output tokens generated.
|
|
214
|
+
staged_files: List of staged file paths.
|
|
215
|
+
diff_preview: Preview of the staged diff.
|
|
216
|
+
"""
|
|
217
|
+
# Save hash
|
|
218
|
+
get_hash_file(repo_root).write_text(context_hash)
|
|
219
|
+
|
|
220
|
+
# Save message
|
|
221
|
+
get_message_file(repo_root).write_text(message)
|
|
222
|
+
|
|
223
|
+
# Save metadata
|
|
224
|
+
metadata = CacheMetadata(
|
|
225
|
+
context_hash=context_hash,
|
|
226
|
+
generated_at=datetime.now(timezone.utc).isoformat(),
|
|
227
|
+
model=model,
|
|
228
|
+
input_tokens=input_tokens,
|
|
229
|
+
output_tokens=output_tokens,
|
|
230
|
+
staged_files=staged_files,
|
|
231
|
+
original_message=message,
|
|
232
|
+
diff_preview=diff_preview,
|
|
233
|
+
)
|
|
234
|
+
get_metadata_file(repo_root).write_text(metadata.model_dump_json(indent=2))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def update_message_cache(repo_root: Path, message: str) -> None:
|
|
238
|
+
"""Update the cached message without changing metadata.
|
|
239
|
+
|
|
240
|
+
Used when user edits the message - keeps original_message intact.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
repo_root: The root directory of the git repository.
|
|
244
|
+
message: The updated commit message.
|
|
245
|
+
"""
|
|
246
|
+
get_message_file(repo_root).write_text(message)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def load_cached_message(repo_root: Path) -> str:
|
|
250
|
+
"""Load the cached message.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
repo_root: The root directory of the git repository.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
The cached commit message string.
|
|
257
|
+
"""
|
|
258
|
+
return get_message_file(repo_root).read_text()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def load_cache_metadata(repo_root: Path) -> Optional[CacheMetadata]:
|
|
262
|
+
"""Load the cache metadata.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
repo_root: The root directory of the git repository.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
CacheMetadata object or None if not found.
|
|
269
|
+
"""
|
|
270
|
+
metadata_file = get_metadata_file(repo_root)
|
|
271
|
+
if not metadata_file.exists():
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
data = json.loads(metadata_file.read_text())
|
|
276
|
+
return CacheMetadata(**data)
|
|
277
|
+
except (json.JSONDecodeError, Exception):
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def invalidate_cache(repo_root: Path) -> None:
|
|
282
|
+
"""Remove all cache files.
|
|
283
|
+
|
|
284
|
+
Call this after a successful commit.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
repo_root: The root directory of the git repository.
|
|
288
|
+
"""
|
|
289
|
+
for file_getter in [get_hash_file, get_message_file, get_metadata_file]:
|
|
290
|
+
file_path = file_getter(repo_root)
|
|
291
|
+
if file_path.exists():
|
|
292
|
+
file_path.unlink()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def extract_staged_files(status_output: str) -> list[str]:
|
|
296
|
+
"""Extract list of staged files from git status output.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
status_output: Output from git status --porcelain=v1 -b
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
List of staged file paths.
|
|
303
|
+
"""
|
|
304
|
+
staged_files = []
|
|
305
|
+
for line in status_output.split("\n"):
|
|
306
|
+
if not line or line.startswith("##"):
|
|
307
|
+
continue
|
|
308
|
+
# Porcelain format: XY filename
|
|
309
|
+
# X = index status, Y = worktree status
|
|
310
|
+
# If X is not space or ?, file is staged
|
|
311
|
+
if len(line) >= 3:
|
|
312
|
+
index_status = line[0]
|
|
313
|
+
if index_status not in (" ", "?"):
|
|
314
|
+
# Handle renamed files: R old -> new
|
|
315
|
+
filename = line[3:]
|
|
316
|
+
if " -> " in filename:
|
|
317
|
+
filename = filename.split(" -> ")[1]
|
|
318
|
+
staged_files.append(filename)
|
|
319
|
+
return staged_files
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def get_diff_preview(diff: str, max_chars: int = 500) -> str:
|
|
323
|
+
"""Get a preview of the diff, truncated if necessary.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
diff: The full staged diff.
|
|
327
|
+
max_chars: Maximum characters for the preview.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Truncated diff preview.
|
|
331
|
+
"""
|
|
332
|
+
if len(diff) <= max_chars:
|
|
333
|
+
return diff
|
|
334
|
+
return diff[:max_chars] + "\n...[truncated]"
|