repr-cli 0.1.0__py3-none-any.whl → 0.2.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.
- repr/__init__.py +1 -1
- repr/api.py +127 -1
- repr/auth.py +66 -2
- repr/cli.py +2143 -663
- repr/config.py +658 -32
- repr/discovery.py +5 -0
- repr/doctor.py +458 -0
- repr/hooks.py +634 -0
- repr/keychain.py +255 -0
- repr/llm.py +506 -0
- repr/openai_analysis.py +92 -21
- repr/privacy.py +333 -0
- repr/storage.py +527 -0
- repr/templates.py +229 -0
- repr/tools.py +202 -0
- repr/ui.py +79 -364
- repr_cli-0.2.1.dist-info/METADATA +263 -0
- repr_cli-0.2.1.dist-info/RECORD +23 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.1.dist-info}/licenses/LICENSE +1 -1
- repr/analyzer.py +0 -915
- repr/highlights.py +0 -712
- repr_cli-0.1.0.dist-info/METADATA +0 -326
- repr_cli-0.1.0.dist-info/RECORD +0 -18
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.1.dist-info}/WHEEL +0 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.1.dist-info}/entry_points.txt +0 -0
- {repr_cli-0.1.0.dist-info → repr_cli-0.2.1.dist-info}/top_level.txt +0 -0
repr/hooks.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git hook management for repr.
|
|
3
|
+
|
|
4
|
+
Handles installation and removal of post-commit hooks that queue commits
|
|
5
|
+
for story generation. Implements local commit queue with file locking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from git import Repo, InvalidGitRepositoryError
|
|
18
|
+
|
|
19
|
+
# Cross-platform file locking
|
|
20
|
+
if sys.platform == "win32":
|
|
21
|
+
import msvcrt
|
|
22
|
+
_USE_FCNTL = False
|
|
23
|
+
else:
|
|
24
|
+
import fcntl
|
|
25
|
+
_USE_FCNTL = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Hook script that queues commits locally (never triggers cloud)
|
|
29
|
+
HOOK_SCRIPT = '''#!/bin/sh
|
|
30
|
+
# repr post-commit hook - queue commits for story generation
|
|
31
|
+
# Auto-generated by repr CLI
|
|
32
|
+
# This hook only queues commits locally; no network calls are made.
|
|
33
|
+
|
|
34
|
+
# Get commit info
|
|
35
|
+
COMMIT_SHA=$(git rev-parse HEAD)
|
|
36
|
+
COMMIT_MSG=$(git log -1 --format="%s" HEAD)
|
|
37
|
+
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
38
|
+
|
|
39
|
+
# Queue the commit
|
|
40
|
+
repr hooks queue "$COMMIT_SHA" --repo "$REPO_ROOT" 2>/dev/null || true
|
|
41
|
+
'''
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_git_repo(path: Path) -> bool:
|
|
45
|
+
"""Check if path is a git repository.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
path: Path to check
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
True if path is a git repo
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
Repo(path)
|
|
55
|
+
return True
|
|
56
|
+
except InvalidGitRepositoryError:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_hook_path(repo_path: Path) -> Path:
|
|
61
|
+
"""Get path to post-commit hook file.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
repo_path: Path to repository root
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Path to post-commit hook
|
|
68
|
+
"""
|
|
69
|
+
return repo_path / ".git" / "hooks" / "post-commit"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_repr_dir(repo_path: Path) -> Path:
|
|
73
|
+
"""Get path to .git/repr/ directory for a repo.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
repo_path: Path to repository root
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Path to .git/repr/ directory
|
|
80
|
+
"""
|
|
81
|
+
return repo_path / ".git" / "repr"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_queue_path(repo_path: Path) -> Path:
|
|
85
|
+
"""Get path to queue.json file for a repo.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
repo_path: Path to repository root
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Path to queue.json file
|
|
92
|
+
"""
|
|
93
|
+
return get_repr_dir(repo_path) / "queue.json"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_repo_id_path(repo_path: Path) -> Path:
|
|
97
|
+
"""Get path to repo_id file.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
repo_path: Path to repository root
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Path to repo_id file
|
|
104
|
+
"""
|
|
105
|
+
return get_repr_dir(repo_path) / "repo_id"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_hook_installed(repo_path: Path) -> bool:
|
|
109
|
+
"""Check if repr hook is installed in repository.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
repo_path: Path to repository root
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
True if hook is installed and contains repr queue command
|
|
116
|
+
"""
|
|
117
|
+
hook_path = get_hook_path(repo_path)
|
|
118
|
+
|
|
119
|
+
if not hook_path.exists():
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
content = hook_path.read_text()
|
|
124
|
+
# Check if it's our hook
|
|
125
|
+
return "repr hooks queue" in content and "# Auto-generated by repr CLI" in content
|
|
126
|
+
except Exception:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def install_hook(repo_path: Path) -> dict[str, Any]:
|
|
131
|
+
"""Install post-commit hook in repository.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
repo_path: Path to repository root
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dict with 'success', 'message', 'already_installed' keys
|
|
138
|
+
"""
|
|
139
|
+
if not is_git_repo(repo_path):
|
|
140
|
+
return {
|
|
141
|
+
"success": False,
|
|
142
|
+
"message": f"Not a git repository: {repo_path}",
|
|
143
|
+
"already_installed": False,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Check if .git is writable
|
|
147
|
+
git_dir = repo_path / ".git"
|
|
148
|
+
if not os.access(git_dir, os.W_OK):
|
|
149
|
+
return {
|
|
150
|
+
"success": False,
|
|
151
|
+
"message": f"Cannot install hook: .git directory is read-only",
|
|
152
|
+
"already_installed": False,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
hook_path = get_hook_path(repo_path)
|
|
156
|
+
|
|
157
|
+
# Ensure hooks directory exists
|
|
158
|
+
hook_path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
# Ensure repr directory exists
|
|
161
|
+
repr_dir = get_repr_dir(repo_path)
|
|
162
|
+
repr_dir.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
|
|
164
|
+
# Generate repo ID if not exists
|
|
165
|
+
repo_id_path = get_repo_id_path(repo_path)
|
|
166
|
+
if not repo_id_path.exists():
|
|
167
|
+
import uuid
|
|
168
|
+
repo_id_path.write_text(str(uuid.uuid4()))
|
|
169
|
+
|
|
170
|
+
# Check if hook already exists
|
|
171
|
+
if hook_path.exists():
|
|
172
|
+
existing_content = hook_path.read_text()
|
|
173
|
+
|
|
174
|
+
# If it's our hook, nothing to do
|
|
175
|
+
if is_hook_installed(repo_path):
|
|
176
|
+
return {
|
|
177
|
+
"success": True,
|
|
178
|
+
"message": "Hook already installed",
|
|
179
|
+
"already_installed": True,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# If there's another hook, append ours
|
|
183
|
+
if existing_content.strip():
|
|
184
|
+
# Backup existing hook
|
|
185
|
+
backup_path = hook_path.with_suffix(".pre-repr")
|
|
186
|
+
backup_path.write_text(existing_content)
|
|
187
|
+
|
|
188
|
+
# Append our hook
|
|
189
|
+
new_content = existing_content.rstrip() + "\n\n" + HOOK_SCRIPT
|
|
190
|
+
hook_path.write_text(new_content)
|
|
191
|
+
|
|
192
|
+
# Make executable
|
|
193
|
+
hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"success": True,
|
|
197
|
+
"message": f"Hook appended (existing hook backed up to {backup_path.name})",
|
|
198
|
+
"already_installed": False,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Create new hook
|
|
202
|
+
hook_path.write_text(HOOK_SCRIPT)
|
|
203
|
+
|
|
204
|
+
# Make executable
|
|
205
|
+
hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
"success": True,
|
|
209
|
+
"message": "Hook installed successfully",
|
|
210
|
+
"already_installed": False,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def remove_hook(repo_path: Path) -> dict[str, Any]:
|
|
215
|
+
"""Remove repr post-commit hook from repository.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
repo_path: Path to repository root
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Dict with 'success', 'message' keys
|
|
222
|
+
"""
|
|
223
|
+
if not is_git_repo(repo_path):
|
|
224
|
+
return {
|
|
225
|
+
"success": False,
|
|
226
|
+
"message": f"Not a git repository: {repo_path}",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
hook_path = get_hook_path(repo_path)
|
|
230
|
+
|
|
231
|
+
if not hook_path.exists():
|
|
232
|
+
return {
|
|
233
|
+
"success": True,
|
|
234
|
+
"message": "No hook to remove",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
content = hook_path.read_text()
|
|
239
|
+
|
|
240
|
+
# If it's entirely our hook, delete the file
|
|
241
|
+
if content.strip() == HOOK_SCRIPT.strip():
|
|
242
|
+
hook_path.unlink()
|
|
243
|
+
return {
|
|
244
|
+
"success": True,
|
|
245
|
+
"message": "Hook removed successfully",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# If our hook is appended, remove just our part
|
|
249
|
+
if "# Auto-generated by repr CLI" in content:
|
|
250
|
+
# Find and remove our section
|
|
251
|
+
lines = content.split("\n")
|
|
252
|
+
new_lines = []
|
|
253
|
+
skip = False
|
|
254
|
+
|
|
255
|
+
for i, line in enumerate(lines):
|
|
256
|
+
if "# repr post-commit hook" in line and "repr CLI" in content[content.index(line):]:
|
|
257
|
+
skip = True
|
|
258
|
+
continue
|
|
259
|
+
if skip:
|
|
260
|
+
# Skip until we hit a line that's clearly not part of our hook
|
|
261
|
+
if line.strip() and not line.startswith("#") and "repr" not in line and "COMMIT" not in line and "REPO" not in line:
|
|
262
|
+
skip = False
|
|
263
|
+
new_lines.append(line)
|
|
264
|
+
continue
|
|
265
|
+
new_lines.append(line)
|
|
266
|
+
|
|
267
|
+
new_content = "\n".join(new_lines).strip()
|
|
268
|
+
|
|
269
|
+
if new_content:
|
|
270
|
+
hook_path.write_text(new_content + "\n")
|
|
271
|
+
return {
|
|
272
|
+
"success": True,
|
|
273
|
+
"message": "repr hook removed (other hooks preserved)",
|
|
274
|
+
}
|
|
275
|
+
else:
|
|
276
|
+
hook_path.unlink()
|
|
277
|
+
return {
|
|
278
|
+
"success": True,
|
|
279
|
+
"message": "Hook file removed (was empty after removing repr hook)",
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"success": False,
|
|
284
|
+
"message": "Hook file exists but doesn't contain repr hook",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
except Exception as e:
|
|
288
|
+
return {
|
|
289
|
+
"success": False,
|
|
290
|
+
"message": f"Error removing hook: {str(e)}",
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_hook_status(repo_path: Path) -> dict[str, Any]:
|
|
295
|
+
"""Get hook installation status for a repository.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
repo_path: Path to repository root
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Dict with 'installed', 'path', 'executable', 'queue_count' keys
|
|
302
|
+
"""
|
|
303
|
+
if not is_git_repo(repo_path):
|
|
304
|
+
return {
|
|
305
|
+
"installed": False,
|
|
306
|
+
"path": None,
|
|
307
|
+
"executable": False,
|
|
308
|
+
"queue_count": 0,
|
|
309
|
+
"error": "Not a git repository",
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
hook_path = get_hook_path(repo_path)
|
|
313
|
+
installed = is_hook_installed(repo_path)
|
|
314
|
+
|
|
315
|
+
executable = False
|
|
316
|
+
if hook_path.exists():
|
|
317
|
+
executable = os.access(hook_path, os.X_OK)
|
|
318
|
+
|
|
319
|
+
# Get queue count
|
|
320
|
+
queue = load_queue(repo_path)
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
"installed": installed,
|
|
324
|
+
"path": str(hook_path) if hook_path.exists() else None,
|
|
325
|
+
"executable": executable,
|
|
326
|
+
"queue_count": len(queue),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ============================================================================
|
|
331
|
+
# Queue Management
|
|
332
|
+
# ============================================================================
|
|
333
|
+
|
|
334
|
+
LOCK_TIMEOUT = 2 # seconds to wait for lock
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class QueueLockError(Exception):
|
|
338
|
+
"""Failed to acquire queue lock."""
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _acquire_lock(lock_path: Path, timeout: float = LOCK_TIMEOUT) -> int:
|
|
343
|
+
"""Acquire a file lock.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
lock_path: Path to lock file
|
|
347
|
+
timeout: Seconds to wait
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
File descriptor
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
QueueLockError: If lock cannot be acquired
|
|
354
|
+
"""
|
|
355
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
|
|
357
|
+
|
|
358
|
+
start = time.time()
|
|
359
|
+
while time.time() - start < timeout:
|
|
360
|
+
try:
|
|
361
|
+
if _USE_FCNTL:
|
|
362
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
363
|
+
else:
|
|
364
|
+
# Windows: lock the first byte of the file
|
|
365
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
366
|
+
return fd
|
|
367
|
+
except (IOError, OSError):
|
|
368
|
+
time.sleep(0.1)
|
|
369
|
+
|
|
370
|
+
os.close(fd)
|
|
371
|
+
raise QueueLockError(f"Could not acquire queue lock after {timeout}s")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _release_lock(fd: int) -> None:
|
|
375
|
+
"""Release a file lock."""
|
|
376
|
+
try:
|
|
377
|
+
if _USE_FCNTL:
|
|
378
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
379
|
+
else:
|
|
380
|
+
# Windows: unlock the first byte
|
|
381
|
+
try:
|
|
382
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
383
|
+
except (IOError, OSError):
|
|
384
|
+
pass # May fail if not locked, ignore
|
|
385
|
+
os.close(fd)
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def load_queue(repo_path: Path) -> list[dict[str, Any]]:
|
|
391
|
+
"""Load commit queue for a repository.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
repo_path: Path to repository root
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
List of queued commit entries
|
|
398
|
+
"""
|
|
399
|
+
queue_path = get_queue_path(repo_path)
|
|
400
|
+
|
|
401
|
+
if not queue_path.exists():
|
|
402
|
+
return []
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
return json.loads(queue_path.read_text())
|
|
406
|
+
except (json.JSONDecodeError, IOError):
|
|
407
|
+
return []
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def save_queue(repo_path: Path, queue: list[dict[str, Any]]) -> None:
|
|
411
|
+
"""Save commit queue for a repository.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
repo_path: Path to repository root
|
|
415
|
+
queue: List of queued commit entries
|
|
416
|
+
"""
|
|
417
|
+
queue_path = get_queue_path(repo_path)
|
|
418
|
+
queue_path.parent.mkdir(parents=True, exist_ok=True)
|
|
419
|
+
queue_path.write_text(json.dumps(queue, indent=2))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def queue_commit(repo_path: Path, commit_sha: str, message: str | None = None) -> bool:
|
|
423
|
+
"""Add a commit to the queue.
|
|
424
|
+
|
|
425
|
+
Uses file locking to handle concurrent commits safely.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
repo_path: Path to repository root
|
|
429
|
+
commit_sha: Full commit SHA
|
|
430
|
+
message: Optional commit message
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
True if queued successfully
|
|
434
|
+
"""
|
|
435
|
+
queue_path = get_queue_path(repo_path)
|
|
436
|
+
lock_path = queue_path.with_suffix(".lock")
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
fd = _acquire_lock(lock_path)
|
|
440
|
+
try:
|
|
441
|
+
queue = load_queue(repo_path)
|
|
442
|
+
|
|
443
|
+
# Check if already queued
|
|
444
|
+
if any(c["sha"] == commit_sha for c in queue):
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
# Add to queue
|
|
448
|
+
queue.append({
|
|
449
|
+
"sha": commit_sha,
|
|
450
|
+
"message": message,
|
|
451
|
+
"queued_at": datetime.now().isoformat(),
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
save_queue(repo_path, queue)
|
|
455
|
+
return True
|
|
456
|
+
|
|
457
|
+
finally:
|
|
458
|
+
_release_lock(fd)
|
|
459
|
+
|
|
460
|
+
except QueueLockError:
|
|
461
|
+
# Could not get lock, skip queuing
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def dequeue_commits(repo_path: Path, commit_shas: list[str]) -> int:
|
|
466
|
+
"""Remove commits from the queue.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
repo_path: Path to repository root
|
|
470
|
+
commit_shas: List of commit SHAs to remove
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Number of commits removed
|
|
474
|
+
"""
|
|
475
|
+
queue_path = get_queue_path(repo_path)
|
|
476
|
+
lock_path = queue_path.with_suffix(".lock")
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
fd = _acquire_lock(lock_path)
|
|
480
|
+
try:
|
|
481
|
+
queue = load_queue(repo_path)
|
|
482
|
+
original_len = len(queue)
|
|
483
|
+
|
|
484
|
+
# Remove specified commits
|
|
485
|
+
queue = [c for c in queue if c["sha"] not in commit_shas]
|
|
486
|
+
|
|
487
|
+
save_queue(repo_path, queue)
|
|
488
|
+
return original_len - len(queue)
|
|
489
|
+
|
|
490
|
+
finally:
|
|
491
|
+
_release_lock(fd)
|
|
492
|
+
|
|
493
|
+
except QueueLockError:
|
|
494
|
+
return 0
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def clear_queue(repo_path: Path) -> int:
|
|
498
|
+
"""Clear all commits from the queue.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
repo_path: Path to repository root
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
Number of commits cleared
|
|
505
|
+
"""
|
|
506
|
+
queue_path = get_queue_path(repo_path)
|
|
507
|
+
lock_path = queue_path.with_suffix(".lock")
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
fd = _acquire_lock(lock_path)
|
|
511
|
+
try:
|
|
512
|
+
queue = load_queue(repo_path)
|
|
513
|
+
count = len(queue)
|
|
514
|
+
save_queue(repo_path, [])
|
|
515
|
+
return count
|
|
516
|
+
finally:
|
|
517
|
+
_release_lock(fd)
|
|
518
|
+
except QueueLockError:
|
|
519
|
+
return 0
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def cleanup_orphaned_commits(repo_path: Path) -> list[str]:
|
|
523
|
+
"""Remove commits from queue that no longer exist in repo history.
|
|
524
|
+
|
|
525
|
+
This handles rebases, amends, and other history rewrites.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
repo_path: Path to repository root
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
List of removed commit SHAs
|
|
532
|
+
"""
|
|
533
|
+
try:
|
|
534
|
+
repo = Repo(repo_path)
|
|
535
|
+
except InvalidGitRepositoryError:
|
|
536
|
+
return []
|
|
537
|
+
|
|
538
|
+
queue = load_queue(repo_path)
|
|
539
|
+
if not queue:
|
|
540
|
+
return []
|
|
541
|
+
|
|
542
|
+
orphaned = []
|
|
543
|
+
valid = []
|
|
544
|
+
|
|
545
|
+
for entry in queue:
|
|
546
|
+
sha = entry["sha"]
|
|
547
|
+
try:
|
|
548
|
+
# Check if commit exists
|
|
549
|
+
repo.commit(sha)
|
|
550
|
+
valid.append(entry)
|
|
551
|
+
except Exception:
|
|
552
|
+
orphaned.append(sha)
|
|
553
|
+
|
|
554
|
+
if orphaned:
|
|
555
|
+
# Save cleaned queue
|
|
556
|
+
queue_path = get_queue_path(repo_path)
|
|
557
|
+
lock_path = queue_path.with_suffix(".lock")
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
fd = _acquire_lock(lock_path)
|
|
561
|
+
try:
|
|
562
|
+
save_queue(repo_path, valid)
|
|
563
|
+
finally:
|
|
564
|
+
_release_lock(fd)
|
|
565
|
+
except QueueLockError:
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
return orphaned
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def get_queue_stats(repo_path: Path) -> dict[str, Any]:
|
|
572
|
+
"""Get queue statistics for a repository.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
repo_path: Path to repository root
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
Dict with queue stats
|
|
579
|
+
"""
|
|
580
|
+
queue = load_queue(repo_path)
|
|
581
|
+
|
|
582
|
+
if not queue:
|
|
583
|
+
return {
|
|
584
|
+
"count": 0,
|
|
585
|
+
"oldest": None,
|
|
586
|
+
"newest": None,
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
# Get timestamps
|
|
590
|
+
timestamps = [c.get("queued_at") for c in queue if c.get("queued_at")]
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
"count": len(queue),
|
|
594
|
+
"oldest": min(timestamps) if timestamps else None,
|
|
595
|
+
"newest": max(timestamps) if timestamps else None,
|
|
596
|
+
"commits": [c["sha"][:7] for c in queue],
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def get_repo_id(repo_path: Path) -> str | None:
|
|
601
|
+
"""Get the stable repo ID.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
repo_path: Path to repository root
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
Repo UUID or None if not set
|
|
608
|
+
"""
|
|
609
|
+
repo_id_path = get_repo_id_path(repo_path)
|
|
610
|
+
if repo_id_path.exists():
|
|
611
|
+
return repo_id_path.read_text().strip()
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def ensure_repo_id(repo_path: Path) -> str:
|
|
616
|
+
"""Ensure repo has a stable ID, creating one if needed.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
repo_path: Path to repository root
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
Repo UUID
|
|
623
|
+
"""
|
|
624
|
+
import uuid
|
|
625
|
+
|
|
626
|
+
repo_id_path = get_repo_id_path(repo_path)
|
|
627
|
+
repo_id_path.parent.mkdir(parents=True, exist_ok=True)
|
|
628
|
+
|
|
629
|
+
if repo_id_path.exists():
|
|
630
|
+
return repo_id_path.read_text().strip()
|
|
631
|
+
|
|
632
|
+
new_id = str(uuid.uuid4())
|
|
633
|
+
repo_id_path.write_text(new_id)
|
|
634
|
+
return new_id
|