repr-cli 0.1.0__py3-none-any.whl → 0.2.2__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/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