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 ADDED
@@ -0,0 +1,3 @@
1
+ """AI Commit Message Generator CLI tool."""
2
+
3
+ __version__ = "0.1.0"
hunknote/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow running hunknote as a module: python -m hunknote"""
2
+
3
+ from hunknote.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
7
+
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]"