elspais 0.9.1__py3-none-any.whl → 0.11.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.
Files changed (75) hide show
  1. elspais/cli.py +123 -1
  2. elspais/commands/changed.py +160 -0
  3. elspais/commands/hash_cmd.py +72 -26
  4. elspais/commands/reformat_cmd.py +458 -0
  5. elspais/commands/trace.py +157 -3
  6. elspais/commands/validate.py +81 -18
  7. elspais/core/git.py +352 -0
  8. elspais/core/models.py +2 -0
  9. elspais/core/parser.py +68 -24
  10. elspais/reformat/__init__.py +50 -0
  11. elspais/reformat/detector.py +119 -0
  12. elspais/reformat/hierarchy.py +246 -0
  13. elspais/reformat/line_breaks.py +220 -0
  14. elspais/reformat/prompts.py +123 -0
  15. elspais/reformat/transformer.py +264 -0
  16. elspais/sponsors/__init__.py +432 -0
  17. elspais/trace_view/__init__.py +54 -0
  18. elspais/trace_view/coverage.py +183 -0
  19. elspais/trace_view/generators/__init__.py +12 -0
  20. elspais/trace_view/generators/base.py +329 -0
  21. elspais/trace_view/generators/csv.py +122 -0
  22. elspais/trace_view/generators/markdown.py +175 -0
  23. elspais/trace_view/html/__init__.py +31 -0
  24. elspais/trace_view/html/generator.py +1006 -0
  25. elspais/trace_view/html/templates/base.html +283 -0
  26. elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
  27. elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
  28. elspais/trace_view/html/templates/components/legend_modal.html +69 -0
  29. elspais/trace_view/html/templates/components/review_panel.html +118 -0
  30. elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
  31. elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
  32. elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
  33. elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
  34. elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
  35. elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
  36. elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
  37. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
  38. elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
  39. elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
  40. elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
  41. elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
  42. elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
  43. elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
  44. elspais/trace_view/html/templates/partials/scripts.js +1741 -0
  45. elspais/trace_view/html/templates/partials/styles.css +1756 -0
  46. elspais/trace_view/models.py +353 -0
  47. elspais/trace_view/review/__init__.py +60 -0
  48. elspais/trace_view/review/branches.py +1149 -0
  49. elspais/trace_view/review/models.py +1205 -0
  50. elspais/trace_view/review/position.py +609 -0
  51. elspais/trace_view/review/server.py +1056 -0
  52. elspais/trace_view/review/status.py +470 -0
  53. elspais/trace_view/review/storage.py +1367 -0
  54. elspais/trace_view/scanning.py +213 -0
  55. elspais/trace_view/specs/README.md +84 -0
  56. elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
  57. elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
  58. elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
  59. elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
  60. elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
  61. elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
  62. elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
  63. elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
  64. elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
  65. elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
  66. elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
  67. elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
  68. elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
  69. elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
  70. {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/METADATA +78 -26
  71. elspais-0.11.0.dist-info/RECORD +101 -0
  72. elspais-0.9.1.dist-info/RECORD +0 -38
  73. {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/WHEEL +0 -0
  74. {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/entry_points.txt +0 -0
  75. {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1149 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Review Branch Management Module for trace_view
4
+
5
+ Handles git branch operations for the review system:
6
+ - Branch naming and parsing
7
+ - Branch creation, checkout, push, fetch
8
+ - Branch listing and discovery
9
+ - Conflict detection
10
+
11
+ Branch naming convention: reviews/{package_id}/{username}
12
+ - Package-first naming enables discovery of all branches for a package
13
+ - User-specific branches enable isolated work without merge conflicts
14
+
15
+ IMPLEMENTS REQUIREMENTS:
16
+ REQ-tv-d00013: Git Branch Management
17
+ """
18
+
19
+ import re
20
+ import subprocess
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import List, Optional, Tuple
25
+
26
+
27
+ # =============================================================================
28
+ # Constants
29
+ # =============================================================================
30
+
31
+ REVIEW_BRANCH_PREFIX = 'reviews/'
32
+
33
+
34
+ # =============================================================================
35
+ # Data Classes (REQ-tv-d00013)
36
+ # =============================================================================
37
+
38
+ @dataclass
39
+ class BranchInfo:
40
+ """
41
+ Metadata about a review branch for CLI display.
42
+
43
+ REQ-tv-d00013: Branch info data class for listing and cleanup operations.
44
+ """
45
+ name: str # Full branch name: reviews/{pkg}/{user}
46
+ package_id: str # Package identifier
47
+ username: str # User who owns the branch
48
+ last_commit_date: datetime # Date of last commit on branch
49
+ is_current: bool # True if this is the current branch
50
+ has_remote: bool # True if remote tracking branch exists
51
+ is_merged: bool # True if merged into main
52
+
53
+ @property
54
+ def age_days(self) -> int:
55
+ """Calculate age in days from last commit."""
56
+ now = datetime.now(timezone.utc)
57
+ if self.last_commit_date.tzinfo is None:
58
+ # Assume UTC if no timezone
59
+ last = self.last_commit_date.replace(tzinfo=timezone.utc)
60
+ else:
61
+ last = self.last_commit_date
62
+ delta = now - last
63
+ return delta.days
64
+
65
+
66
+ # =============================================================================
67
+ # Branch Naming (REQ-tv-d00013-A, B)
68
+ # =============================================================================
69
+
70
+ def get_review_branch_name(package_id: str, user: str) -> str:
71
+ """
72
+ Generate a review branch name from package and user.
73
+
74
+ REQ-tv-d00013-A: Review branches SHALL follow the naming convention
75
+ `reviews/{package_id}/{username}`.
76
+ REQ-tv-d00013-B: This function SHALL return the formatted branch name.
77
+
78
+ Args:
79
+ package_id: Review package identifier (e.g., 'default', 'q1-2025-review')
80
+ user: Username
81
+
82
+ Returns:
83
+ Branch name in format: reviews/{package}/{user}
84
+
85
+ Examples:
86
+ >>> get_review_branch_name('default', 'alice')
87
+ 'reviews/default/alice'
88
+ >>> get_review_branch_name('q1-review', 'bob')
89
+ 'reviews/q1-review/bob'
90
+ """
91
+ # Sanitize both package and user for git branch
92
+ sanitized_package = _sanitize_branch_name(package_id)
93
+ sanitized_user = _sanitize_branch_name(user)
94
+ return f"{REVIEW_BRANCH_PREFIX}{sanitized_package}/{sanitized_user}"
95
+
96
+
97
+ def _sanitize_branch_name(name: str) -> str:
98
+ """
99
+ Sanitize a string for use in a git branch name.
100
+
101
+ Replaces spaces with hyphens and removes invalid characters.
102
+ """
103
+ # Replace spaces with hyphens
104
+ name = name.replace(' ', '-')
105
+ # Remove invalid characters (keep alphanumeric, hyphen, underscore)
106
+ name = re.sub(r'[^a-zA-Z0-9_-]', '', name)
107
+ # Remove leading/trailing hyphens
108
+ name = name.strip('-')
109
+ # Convert to lowercase
110
+ return name.lower()
111
+
112
+
113
+ # =============================================================================
114
+ # Branch Parsing (REQ-tv-d00013-C, D)
115
+ # =============================================================================
116
+
117
+ def parse_review_branch_name(branch_name: str) -> Optional[Tuple[str, str]]:
118
+ """
119
+ Parse a review branch name into (package_id, user).
120
+
121
+ REQ-tv-d00013-C: This function SHALL extract and return a tuple of
122
+ `(package_id, username)` from a valid branch name.
123
+
124
+ Args:
125
+ branch_name: Full branch name
126
+
127
+ Returns:
128
+ Tuple of (package_id, user) or None if not a valid review branch
129
+
130
+ Examples:
131
+ >>> parse_review_branch_name('reviews/default/alice')
132
+ ('default', 'alice')
133
+ >>> parse_review_branch_name('reviews/q1-review/bob')
134
+ ('q1-review', 'bob')
135
+ >>> parse_review_branch_name('main')
136
+ None
137
+ """
138
+ if not is_review_branch(branch_name):
139
+ return None
140
+
141
+ # Remove prefix
142
+ remainder = branch_name[len(REVIEW_BRANCH_PREFIX):]
143
+ parts = remainder.split('/', 1)
144
+
145
+ if len(parts) != 2 or not parts[0] or not parts[1]:
146
+ return None
147
+
148
+ # Returns (package_id, user)
149
+ return (parts[0], parts[1])
150
+
151
+
152
+ def is_review_branch(branch_name: str) -> bool:
153
+ """
154
+ Check if a branch name is a valid review branch.
155
+
156
+ REQ-tv-d00013-D: This function SHALL return True only for branches
157
+ matching the `reviews/{package}/{user}` pattern.
158
+
159
+ Args:
160
+ branch_name: Branch name to check
161
+
162
+ Returns:
163
+ True if valid review branch format (reviews/{package}/{user})
164
+
165
+ Examples:
166
+ >>> is_review_branch('reviews/default/alice')
167
+ True
168
+ >>> is_review_branch('reviews/q1-review/bob')
169
+ True
170
+ >>> is_review_branch('main')
171
+ False
172
+ >>> is_review_branch('reviews/default') # Missing user
173
+ False
174
+ """
175
+ if not branch_name.startswith(REVIEW_BRANCH_PREFIX):
176
+ return False
177
+
178
+ remainder = branch_name[len(REVIEW_BRANCH_PREFIX):]
179
+ parts = remainder.split('/', 1)
180
+
181
+ # Must have both package and user
182
+ return len(parts) == 2 and bool(parts[0]) and bool(parts[1])
183
+
184
+
185
+ # =============================================================================
186
+ # Git Utilities
187
+ # =============================================================================
188
+
189
+ def _run_git(repo_root: Path, args: List[str],
190
+ check: bool = False) -> subprocess.CompletedProcess:
191
+ """
192
+ Run a git command in the repository.
193
+
194
+ Args:
195
+ repo_root: Repository root path
196
+ args: Git command arguments
197
+ check: If True, raise on non-zero exit code
198
+
199
+ Returns:
200
+ CompletedProcess result
201
+ """
202
+ try:
203
+ return subprocess.run(
204
+ ['git'] + args,
205
+ cwd=repo_root,
206
+ capture_output=True,
207
+ text=True,
208
+ check=check
209
+ )
210
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
211
+ # Return a fake failed result
212
+ return subprocess.CompletedProcess(
213
+ args=['git'] + args,
214
+ returncode=1,
215
+ stdout='',
216
+ stderr='Error running git'
217
+ )
218
+
219
+
220
+ def get_current_branch(repo_root: Path) -> Optional[str]:
221
+ """
222
+ Get the current git branch name.
223
+
224
+ Args:
225
+ repo_root: Repository root path
226
+
227
+ Returns:
228
+ Branch name or None if not in a git repo
229
+ """
230
+ result = _run_git(repo_root, ['rev-parse', '--abbrev-ref', 'HEAD'])
231
+ if result.returncode != 0:
232
+ return None
233
+ return result.stdout.strip()
234
+
235
+
236
+ def get_remote_name(repo_root: Path) -> Optional[str]:
237
+ """
238
+ Get the default remote name (usually 'origin').
239
+
240
+ Args:
241
+ repo_root: Repository root path
242
+
243
+ Returns:
244
+ Remote name or None if no remotes configured
245
+ """
246
+ result = _run_git(repo_root, ['remote'])
247
+ if result.returncode != 0 or not result.stdout.strip():
248
+ return None
249
+ # Return first remote
250
+ return result.stdout.strip().split('\n')[0]
251
+
252
+
253
+ # =============================================================================
254
+ # Git Audit Trail (REQ-d00098)
255
+ # =============================================================================
256
+
257
+ def get_head_commit_hash(repo_root: Path) -> Optional[str]:
258
+ """
259
+ Get the current HEAD commit hash (full 40 characters).
260
+
261
+ REQ-d00098-A: Package SHALL record creationCommitHash when created.
262
+ REQ-d00098-C: Package SHALL update lastReviewedCommitHash on each comment activity.
263
+
264
+ Args:
265
+ repo_root: Repository root path
266
+
267
+ Returns:
268
+ Full commit hash (40 chars) or None if not in a git repo
269
+ """
270
+ result = _run_git(repo_root, ['rev-parse', 'HEAD'])
271
+ if result.returncode != 0:
272
+ return None
273
+ return result.stdout.strip()
274
+
275
+
276
+ def get_short_commit_hash(repo_root: Path, length: int = 7) -> Optional[str]:
277
+ """
278
+ Get the current HEAD commit hash (short version).
279
+
280
+ Args:
281
+ repo_root: Repository root path
282
+ length: Length of short hash (default: 7)
283
+
284
+ Returns:
285
+ Short commit hash or None if not in a git repo
286
+ """
287
+ result = _run_git(repo_root, ['rev-parse', f'--short={length}', 'HEAD'])
288
+ if result.returncode != 0:
289
+ return None
290
+ return result.stdout.strip()
291
+
292
+
293
+ def get_git_context(repo_root: Path) -> dict:
294
+ """
295
+ Get current git context (branch name and commit hash) for audit trail.
296
+
297
+ REQ-d00098: Track git context for review packages.
298
+
299
+ Args:
300
+ repo_root: Repository root path
301
+
302
+ Returns:
303
+ Dictionary with 'branchName' and 'commitHash' keys (values may be None)
304
+ """
305
+ return {
306
+ 'branchName': get_current_branch(repo_root),
307
+ 'commitHash': get_head_commit_hash(repo_root)
308
+ }
309
+
310
+
311
+ def commit_exists(repo_root: Path, commit_hash: str) -> bool:
312
+ """
313
+ Check if a commit exists in the repository.
314
+
315
+ REQ-d00098-F: Commit tracking SHALL handle squash-merge scenarios gracefully.
316
+
317
+ This is useful for checking if archived commit hashes still exist after
318
+ squash-merge operations.
319
+
320
+ Args:
321
+ repo_root: Repository root path
322
+ commit_hash: Commit hash to check (full or short)
323
+
324
+ Returns:
325
+ True if commit exists, False otherwise
326
+ """
327
+ result = _run_git(repo_root, ['cat-file', '-t', commit_hash])
328
+ return result.returncode == 0 and result.stdout.strip() == 'commit'
329
+
330
+
331
+ def branch_exists(repo_root: Path, branch_name: str) -> bool:
332
+ """
333
+ Check if a local branch exists.
334
+
335
+ Args:
336
+ repo_root: Repository root path
337
+ branch_name: Branch name to check
338
+
339
+ Returns:
340
+ True if branch exists locally
341
+ """
342
+ result = _run_git(repo_root, ['rev-parse', '--verify', f'refs/heads/{branch_name}'])
343
+ return result.returncode == 0
344
+
345
+
346
+ def remote_branch_exists(repo_root: Path, branch_name: str,
347
+ remote: str = 'origin') -> bool:
348
+ """
349
+ Check if a remote branch exists.
350
+
351
+ Args:
352
+ repo_root: Repository root path
353
+ branch_name: Branch name to check
354
+ remote: Remote name
355
+
356
+ Returns:
357
+ True if branch exists on remote
358
+ """
359
+ result = _run_git(repo_root, ['rev-parse', '--verify', f'refs/remotes/{remote}/{branch_name}'])
360
+ return result.returncode == 0
361
+
362
+
363
+ # =============================================================================
364
+ # Branch Metadata (REQ-tv-d00013 Service Layer)
365
+ # =============================================================================
366
+
367
+ def get_branch_last_commit_date(repo_root: Path, branch_name: str) -> Optional[datetime]:
368
+ """
369
+ Get the date of the last commit on a branch.
370
+
371
+ REQ-tv-d00013: Service layer function for CLI branch listing.
372
+
373
+ Args:
374
+ repo_root: Repository root path
375
+ branch_name: Full branch name (e.g., 'reviews/default/alice')
376
+
377
+ Returns:
378
+ datetime of last commit in UTC, or None if branch doesn't exist
379
+
380
+ Examples:
381
+ >>> get_branch_last_commit_date(repo, 'reviews/default/alice')
382
+ datetime.datetime(2025, 1, 8, 12, 30, 45, tzinfo=timezone.utc)
383
+ """
384
+ # Use git log to get the commit date in ISO format
385
+ result = _run_git(repo_root, [
386
+ 'log', '-1', '--format=%cI', branch_name
387
+ ])
388
+ if result.returncode != 0 or not result.stdout.strip():
389
+ return None
390
+
391
+ date_str = result.stdout.strip()
392
+ try:
393
+ # Parse ISO 8601 date format (e.g., 2025-01-08T12:30:45+00:00)
394
+ dt = datetime.fromisoformat(date_str)
395
+ # Ensure UTC timezone
396
+ if dt.tzinfo is None:
397
+ dt = dt.replace(tzinfo=timezone.utc)
398
+ return dt
399
+ except ValueError:
400
+ return None
401
+
402
+
403
+ def is_branch_merged(repo_root: Path, branch_name: str,
404
+ target_branch: str = 'main') -> bool:
405
+ """
406
+ Check if a branch has been merged into the target branch.
407
+
408
+ REQ-tv-d00013: Service layer function for cleanup operations.
409
+
410
+ Args:
411
+ repo_root: Repository root path
412
+ branch_name: Branch to check (e.g., 'reviews/default/alice')
413
+ target_branch: Target branch to check against (default: 'main')
414
+
415
+ Returns:
416
+ True if branch_name is fully merged into target_branch
417
+
418
+ Examples:
419
+ >>> is_branch_merged(repo, 'reviews/default/alice')
420
+ True # alice's branch has been merged to main
421
+ >>> is_branch_merged(repo, 'reviews/default/bob', 'develop')
422
+ False # bob's branch not merged to develop
423
+ """
424
+ # git branch --merged <target> lists branches merged into target
425
+ result = _run_git(repo_root, ['branch', '--merged', target_branch])
426
+ if result.returncode != 0:
427
+ return False
428
+
429
+ # Parse branch list and check if our branch is in it
430
+ merged_branches = []
431
+ for line in result.stdout.strip().split('\n'):
432
+ branch = line.strip().lstrip('* ')
433
+ if branch:
434
+ merged_branches.append(branch)
435
+
436
+ return branch_name in merged_branches
437
+
438
+
439
+ def has_unpushed_commits(repo_root: Path, branch_name: str,
440
+ remote: str = 'origin') -> bool:
441
+ """
442
+ Check if a branch has commits not pushed to remote.
443
+
444
+ REQ-tv-d00013: Safety check before branch deletion.
445
+
446
+ Args:
447
+ repo_root: Repository root path
448
+ branch_name: Branch to check
449
+ remote: Remote name (default: 'origin')
450
+
451
+ Returns:
452
+ True if branch has commits not on remote, or remote doesn't exist
453
+ """
454
+ # Check if remote branch exists
455
+ if not remote_branch_exists(repo_root, branch_name, remote):
456
+ # No remote tracking - consider it as "has unpushed"
457
+ # (unless there's no remote at all)
458
+ if get_remote_name(repo_root) is None:
459
+ return False # No remote configured, nothing to push
460
+ return True # Remote exists but branch not pushed
461
+
462
+ # Compare local and remote
463
+ result = _run_git(repo_root, [
464
+ 'rev-list', '--count',
465
+ f'{remote}/{branch_name}..{branch_name}'
466
+ ])
467
+ if result.returncode != 0:
468
+ return True # Assume unpushed if we can't check
469
+
470
+ try:
471
+ count = int(result.stdout.strip())
472
+ return count > 0
473
+ except ValueError:
474
+ return True
475
+
476
+
477
+ def get_branch_info(repo_root: Path, branch_name: str) -> Optional[BranchInfo]:
478
+ """
479
+ Get detailed metadata about a review branch.
480
+
481
+ REQ-tv-d00013: Service layer function combining all branch metadata.
482
+
483
+ Args:
484
+ repo_root: Repository root path
485
+ branch_name: Full branch name (e.g., 'reviews/default/alice')
486
+
487
+ Returns:
488
+ BranchInfo dataclass with all metadata, or None if not a valid review branch
489
+
490
+ Examples:
491
+ >>> info = get_branch_info(repo, 'reviews/default/alice')
492
+ >>> info.package_id
493
+ 'default'
494
+ >>> info.age_days
495
+ 3
496
+ """
497
+ # Must be a valid review branch
498
+ parsed = parse_review_branch_name(branch_name)
499
+ if parsed is None:
500
+ return None
501
+
502
+ package_id, username = parsed
503
+
504
+ # Must exist locally
505
+ if not branch_exists(repo_root, branch_name):
506
+ return None
507
+
508
+ # Get last commit date
509
+ last_commit = get_branch_last_commit_date(repo_root, branch_name)
510
+ if last_commit is None:
511
+ # Branch exists but can't get commit date - use epoch
512
+ last_commit = datetime(1970, 1, 1, tzinfo=timezone.utc)
513
+
514
+ # Check if current branch
515
+ current = get_current_branch(repo_root)
516
+ is_current = (current == branch_name)
517
+
518
+ # Check remote existence
519
+ has_remote = remote_branch_exists(repo_root, branch_name)
520
+
521
+ # Check if merged
522
+ is_merged = is_branch_merged(repo_root, branch_name)
523
+
524
+ return BranchInfo(
525
+ name=branch_name,
526
+ package_id=package_id,
527
+ username=username,
528
+ last_commit_date=last_commit,
529
+ is_current=is_current,
530
+ has_remote=has_remote,
531
+ is_merged=is_merged
532
+ )
533
+
534
+
535
+ # =============================================================================
536
+ # Package Context (REQ-tv-d00013-F)
537
+ # =============================================================================
538
+
539
+ def get_current_package_context(repo_root: Path) -> Tuple[Optional[str], Optional[str]]:
540
+ """
541
+ Get current (package_id, user) from branch name.
542
+
543
+ REQ-tv-d00013-F: This function SHALL return `(package_id, username)` when
544
+ on a review branch, or `(None, None)` otherwise.
545
+
546
+ Args:
547
+ repo_root: Repository root path
548
+
549
+ Returns:
550
+ Tuple of (package_id, user) or (None, None) if not on a review branch
551
+
552
+ Examples:
553
+ >>> get_current_package_context(repo)
554
+ ('q1-review', 'alice') # When on reviews/q1-review/alice
555
+ >>> get_current_package_context(repo)
556
+ (None, None) # When on main branch
557
+ """
558
+ current_branch = get_current_branch(repo_root)
559
+ if not current_branch:
560
+ return (None, None)
561
+
562
+ parsed = parse_review_branch_name(current_branch)
563
+ if parsed:
564
+ return parsed
565
+ return (None, None)
566
+
567
+
568
+ # =============================================================================
569
+ # Branch Discovery (REQ-tv-d00013-E)
570
+ # =============================================================================
571
+
572
+ def list_package_branches(repo_root: Path, package_id: str) -> List[str]:
573
+ """
574
+ List all local review branches for a specific package.
575
+
576
+ REQ-tv-d00013-E: This function SHALL return all branch names for a given
577
+ package across all users.
578
+
579
+ Args:
580
+ repo_root: Repository root path
581
+ package_id: Package identifier (e.g., 'default', 'q1-review')
582
+
583
+ Returns:
584
+ List of branch names matching reviews/{package_id}/*
585
+
586
+ Examples:
587
+ >>> list_package_branches(repo, 'default')
588
+ ['reviews/default/alice', 'reviews/default/bob']
589
+ """
590
+ sanitized_package = _sanitize_branch_name(package_id)
591
+ pattern = f"{REVIEW_BRANCH_PREFIX}{sanitized_package}/*"
592
+ return _list_branches_by_pattern(repo_root, pattern)
593
+
594
+
595
+ def _list_branches_by_pattern(repo_root: Path, pattern: str) -> List[str]:
596
+ """
597
+ List local branches matching a pattern.
598
+
599
+ Args:
600
+ repo_root: Repository root path
601
+ pattern: Git branch pattern (e.g., 'reviews/default/*')
602
+
603
+ Returns:
604
+ List of matching branch names
605
+ """
606
+ result = _run_git(repo_root, ['branch', '--list', pattern])
607
+ if result.returncode != 0:
608
+ return []
609
+
610
+ branches = []
611
+ for line in result.stdout.strip().split('\n'):
612
+ branch = line.strip().lstrip('* ')
613
+ if branch and is_review_branch(branch):
614
+ branches.append(branch)
615
+
616
+ return branches
617
+
618
+
619
+ def list_local_review_branches(repo_root: Path,
620
+ user: Optional[str] = None) -> List[str]:
621
+ """
622
+ List all local review branches.
623
+
624
+ Args:
625
+ repo_root: Repository root path
626
+ user: Optional filter by username (matches second component of branch)
627
+
628
+ Returns:
629
+ List of branch names
630
+ """
631
+ result = _run_git(repo_root, ['branch', '--list', 'reviews/*'])
632
+ if result.returncode != 0:
633
+ return []
634
+
635
+ branches = []
636
+ for line in result.stdout.strip().split('\n'):
637
+ # Remove leading * and whitespace
638
+ branch = line.strip().lstrip('* ')
639
+ if branch and is_review_branch(branch):
640
+ if user is None:
641
+ branches.append(branch)
642
+ else:
643
+ parsed = parse_review_branch_name(branch)
644
+ # User is second component: reviews/{package}/{user}
645
+ if parsed and parsed[1] == user:
646
+ branches.append(branch)
647
+
648
+ return branches
649
+
650
+
651
+ # =============================================================================
652
+ # Branch Operations
653
+ # =============================================================================
654
+
655
+ def create_review_branch(repo_root: Path, package_id: str, user: str) -> str:
656
+ """
657
+ Create a new review branch.
658
+
659
+ Args:
660
+ repo_root: Repository root path
661
+ package_id: Review package identifier
662
+ user: Username
663
+
664
+ Returns:
665
+ Created branch name (reviews/{package}/{user})
666
+
667
+ Raises:
668
+ ValueError: If branch already exists
669
+ RuntimeError: If branch creation fails
670
+ """
671
+ branch_name = get_review_branch_name(package_id, user)
672
+
673
+ if branch_exists(repo_root, branch_name):
674
+ raise ValueError(f"Branch already exists: {branch_name}")
675
+
676
+ result = _run_git(repo_root, ['branch', branch_name])
677
+ if result.returncode != 0:
678
+ raise RuntimeError(f"Failed to create branch: {result.stderr}")
679
+
680
+ return branch_name
681
+
682
+
683
+ def checkout_review_branch(repo_root: Path, package_id: str, user: str) -> bool:
684
+ """
685
+ Checkout a review branch.
686
+
687
+ Args:
688
+ repo_root: Repository root path
689
+ package_id: Review package identifier
690
+ user: Username
691
+
692
+ Returns:
693
+ True if checkout succeeded, False if branch doesn't exist
694
+ """
695
+ branch_name = get_review_branch_name(package_id, user)
696
+
697
+ if not branch_exists(repo_root, branch_name):
698
+ return False
699
+
700
+ result = _run_git(repo_root, ['checkout', branch_name])
701
+ return result.returncode == 0
702
+
703
+
704
+ # =============================================================================
705
+ # Change Detection
706
+ # =============================================================================
707
+
708
+ def has_uncommitted_changes(repo_root: Path) -> bool:
709
+ """
710
+ Check if there are uncommitted changes.
711
+
712
+ REQ-tv-d00013-H: Part of conflict detection - detects local changes.
713
+
714
+ Args:
715
+ repo_root: Repository root path
716
+
717
+ Returns:
718
+ True if there are uncommitted changes (staged or unstaged)
719
+ """
720
+ result = _run_git(repo_root, ['status', '--porcelain'])
721
+ return bool(result.stdout.strip())
722
+
723
+
724
+ def has_reviews_changes(repo_root: Path) -> bool:
725
+ """
726
+ Check if there are uncommitted changes in .reviews/ directory.
727
+
728
+ Args:
729
+ repo_root: Repository root path
730
+
731
+ Returns:
732
+ True if .reviews/ has uncommitted changes
733
+ """
734
+ reviews_dir = repo_root / '.reviews'
735
+ if not reviews_dir.exists():
736
+ return False
737
+
738
+ result = _run_git(repo_root, ['status', '--porcelain', '.reviews/'])
739
+ return bool(result.stdout.strip())
740
+
741
+
742
+ def has_conflicts(repo_root: Path) -> bool:
743
+ """
744
+ Check if there are merge conflicts in the repository.
745
+
746
+ REQ-tv-d00013-H: Branch operations SHALL detect and report conflicts.
747
+
748
+ Args:
749
+ repo_root: Repository root path
750
+
751
+ Returns:
752
+ True if there are unresolved merge conflicts
753
+ """
754
+ # Check for merge in progress
755
+ git_dir = repo_root / '.git'
756
+ if (git_dir / 'MERGE_HEAD').exists():
757
+ # Merge in progress, check for conflict markers
758
+ result = _run_git(repo_root, ['diff', '--check'])
759
+ return result.returncode != 0
760
+
761
+ # Check for conflict markers in staged files
762
+ result = _run_git(repo_root, ['diff', '--cached', '--check'])
763
+ if result.returncode != 0:
764
+ return True
765
+
766
+ # Also check working tree
767
+ result = _run_git(repo_root, ['diff', '--check'])
768
+ return result.returncode != 0
769
+
770
+
771
+ # =============================================================================
772
+ # Commit and Push Operations (REQ-tv-d00013-G)
773
+ # =============================================================================
774
+
775
+ def commit_reviews(repo_root: Path, message: str, user: str = 'system') -> bool:
776
+ """
777
+ Commit changes to .reviews/ directory.
778
+
779
+ Args:
780
+ repo_root: Repository root path
781
+ message: Commit message
782
+ user: Username for commit attribution
783
+
784
+ Returns:
785
+ True if commit succeeded (or no changes to commit)
786
+ """
787
+ # Check if there are changes to commit
788
+ if not has_reviews_changes(repo_root):
789
+ return True # No changes, success
790
+
791
+ # Stage .reviews/ changes
792
+ result = _run_git(repo_root, ['add', '.reviews/'])
793
+ if result.returncode != 0:
794
+ return False
795
+
796
+ # Commit with message
797
+ full_message = f"[review] {message}\n\nBy: {user}"
798
+ result = _run_git(repo_root, ['commit', '-m', full_message])
799
+ return result.returncode == 0
800
+
801
+
802
+ def commit_and_push_reviews(
803
+ repo_root: Path,
804
+ message: str,
805
+ user: str = 'system',
806
+ remote: str = 'origin'
807
+ ) -> Tuple[bool, str]:
808
+ """
809
+ Commit changes to .reviews/ and push to remote.
810
+
811
+ REQ-tv-d00013-G: This function SHALL commit all changes in `.reviews/`
812
+ and push to the remote tracking branch.
813
+
814
+ Args:
815
+ repo_root: Repository root path
816
+ message: Commit message describing the change
817
+ user: Username for commit attribution
818
+ remote: Remote name to push to
819
+
820
+ Returns:
821
+ Tuple of (success: bool, message: str)
822
+ """
823
+ # Check if there are changes
824
+ if not has_reviews_changes(repo_root):
825
+ return (True, 'No changes to commit')
826
+
827
+ # Stage .reviews/ changes
828
+ result = _run_git(repo_root, ['add', '.reviews/'])
829
+ if result.returncode != 0:
830
+ return (False, f'Failed to stage changes: {result.stderr}')
831
+
832
+ # Commit with message
833
+ full_message = f"[review] {message}\n\nBy: {user}"
834
+ result = _run_git(repo_root, ['commit', '-m', full_message])
835
+ if result.returncode != 0:
836
+ return (False, f'Failed to commit: {result.stderr}')
837
+
838
+ # Check if remote exists
839
+ if get_remote_name(repo_root) is None:
840
+ return (True, 'Committed locally (no remote configured)')
841
+
842
+ # Push to remote
843
+ current_branch = get_current_branch(repo_root)
844
+ if current_branch:
845
+ push_result = _run_git(repo_root, ['push', remote, current_branch])
846
+ if push_result.returncode == 0:
847
+ return (True, 'Committed and pushed successfully')
848
+ else:
849
+ # Commit succeeded but push failed - still return success for commit
850
+ return (True, f'Committed locally (push failed: {push_result.stderr})')
851
+
852
+ return (True, 'Committed locally')
853
+
854
+
855
+ # =============================================================================
856
+ # Fetch Operations (REQ-tv-d00013-I)
857
+ # =============================================================================
858
+
859
+ def fetch_package_branches(repo_root: Path, package_id: str,
860
+ remote: str = 'origin') -> List[str]:
861
+ """
862
+ Fetch all remote branches for a package.
863
+
864
+ REQ-tv-d00013-I: This function SHALL fetch all remote branches for a
865
+ package to enable merge operations.
866
+
867
+ Args:
868
+ repo_root: Repository root path
869
+ package_id: Package identifier
870
+ remote: Remote name
871
+
872
+ Returns:
873
+ List of fetched branch names for the package
874
+ """
875
+ # Check if remote exists
876
+ if get_remote_name(repo_root) is None:
877
+ return []
878
+
879
+ sanitized_package = _sanitize_branch_name(package_id)
880
+ refspec = f'refs/heads/{REVIEW_BRANCH_PREFIX}{sanitized_package}/*:refs/remotes/{remote}/{REVIEW_BRANCH_PREFIX}{sanitized_package}/*'
881
+
882
+ # Fetch the specific package branches
883
+ result = _run_git(repo_root, ['fetch', remote, refspec])
884
+
885
+ # Even if fetch fails (e.g., no matching refs), list what we have
886
+ branches = []
887
+ list_result = _run_git(repo_root, ['branch', '-r', '--list', f'{remote}/{REVIEW_BRANCH_PREFIX}{sanitized_package}/*'])
888
+
889
+ if list_result.returncode == 0:
890
+ for line in list_result.stdout.strip().split('\n'):
891
+ branch = line.strip()
892
+ if branch:
893
+ branches.append(branch)
894
+
895
+ return branches
896
+
897
+
898
+ def fetch_review_branches(repo_root: Path, remote: str = 'origin') -> bool:
899
+ """
900
+ Fetch all review branches from remote.
901
+
902
+ Args:
903
+ repo_root: Repository root path
904
+ remote: Remote name
905
+
906
+ Returns:
907
+ True if fetch succeeded
908
+ """
909
+ if get_remote_name(repo_root) is None:
910
+ return False
911
+
912
+ result = _run_git(repo_root, ['fetch', remote, '--prune'])
913
+ return result.returncode == 0
914
+
915
+
916
+ # =============================================================================
917
+ # Branch Listing and Cleanup (REQ-tv-d00013 CLI Service Layer)
918
+ # =============================================================================
919
+
920
+ @dataclass
921
+ class CleanupResult:
922
+ """
923
+ Result of a branch cleanup operation.
924
+
925
+ REQ-tv-d00013: Cleanup result for CLI feedback.
926
+ """
927
+ deleted_local: List[str] # Branches deleted locally
928
+ deleted_remote: List[str] # Branches deleted from remote
929
+ skipped_current: List[str] # Skipped because current branch
930
+ skipped_unpushed: List[str] # Skipped because has unpushed commits
931
+ skipped_unmerged: List[str] # Skipped because not merged
932
+ errors: List[Tuple[str, str]] # (branch, error_message) pairs
933
+
934
+
935
+ def list_review_branches_with_info(
936
+ repo_root: Path,
937
+ package_id: Optional[str] = None,
938
+ user: Optional[str] = None
939
+ ) -> List[BranchInfo]:
940
+ """
941
+ List all review branches with their metadata.
942
+
943
+ REQ-tv-d00013: Service layer function for CLI --review-branches.
944
+
945
+ Args:
946
+ repo_root: Repository root path
947
+ package_id: Optional filter by package ID
948
+ user: Optional filter by username
949
+
950
+ Returns:
951
+ List of BranchInfo objects for matching branches
952
+
953
+ Examples:
954
+ >>> branches = list_review_branches_with_info(repo)
955
+ >>> for b in branches:
956
+ ... print(f"{b.name}: {b.age_days}d old, merged={b.is_merged}")
957
+ """
958
+ # Get all review branches
959
+ if package_id:
960
+ branch_names = list_package_branches(repo_root, package_id)
961
+ else:
962
+ branch_names = list_local_review_branches(repo_root)
963
+
964
+ # Filter by user if specified
965
+ if user:
966
+ filtered = []
967
+ for name in branch_names:
968
+ parsed = parse_review_branch_name(name)
969
+ if parsed and parsed[1] == user:
970
+ filtered.append(name)
971
+ branch_names = filtered
972
+
973
+ # Get info for each branch
974
+ branches = []
975
+ for name in branch_names:
976
+ info = get_branch_info(repo_root, name)
977
+ if info:
978
+ branches.append(info)
979
+
980
+ # Sort by last commit date (most recent first)
981
+ branches.sort(key=lambda b: b.last_commit_date, reverse=True)
982
+
983
+ return branches
984
+
985
+
986
+ def delete_review_branch(
987
+ repo_root: Path,
988
+ branch_name: str,
989
+ delete_remote: bool = False,
990
+ force: bool = False,
991
+ remote: str = 'origin'
992
+ ) -> Tuple[bool, str]:
993
+ """
994
+ Delete a review branch with safety checks.
995
+
996
+ REQ-tv-d00013: Service layer function for CLI cleanup.
997
+
998
+ Args:
999
+ repo_root: Repository root path
1000
+ branch_name: Branch name to delete
1001
+ delete_remote: Also delete the remote branch
1002
+ force: Force deletion even if not merged
1003
+ remote: Remote name (default: 'origin')
1004
+
1005
+ Returns:
1006
+ Tuple of (success: bool, message: str)
1007
+
1008
+ Safety checks:
1009
+ - Never deletes current branch
1010
+ - Warns about unmerged branches (unless force=True)
1011
+ - Warns about unpushed commits (unless force=True)
1012
+
1013
+ Examples:
1014
+ >>> success, msg = delete_review_branch(repo, 'reviews/default/alice')
1015
+ >>> if success:
1016
+ ... print("Deleted!")
1017
+ """
1018
+ # Safety check: never delete current branch
1019
+ current = get_current_branch(repo_root)
1020
+ if current == branch_name:
1021
+ return (False, f"Cannot delete current branch: {branch_name}")
1022
+
1023
+ # Safety check: verify it's a review branch
1024
+ if not is_review_branch(branch_name):
1025
+ return (False, f"Not a review branch: {branch_name}")
1026
+
1027
+ # Safety check: verify branch exists
1028
+ if not branch_exists(repo_root, branch_name):
1029
+ return (False, f"Branch does not exist: {branch_name}")
1030
+
1031
+ # Safety check: check for unpushed commits (unless force)
1032
+ if not force and has_unpushed_commits(repo_root, branch_name, remote):
1033
+ return (False, f"Branch has unpushed commits: {branch_name}")
1034
+
1035
+ # Safety check: check if merged (unless force)
1036
+ if not force and not is_branch_merged(repo_root, branch_name):
1037
+ return (False, f"Branch is not merged into main: {branch_name}")
1038
+
1039
+ # Delete local branch
1040
+ delete_flag = '-D' if force else '-d'
1041
+ result = _run_git(repo_root, ['branch', delete_flag, branch_name])
1042
+ if result.returncode != 0:
1043
+ return (False, f"Failed to delete local branch: {result.stderr}")
1044
+
1045
+ # Delete remote branch if requested
1046
+ if delete_remote and remote_branch_exists(repo_root, branch_name, remote):
1047
+ result = _run_git(repo_root, ['push', remote, '--delete', branch_name])
1048
+ if result.returncode != 0:
1049
+ return (True, f"Deleted local branch, but failed to delete remote: {result.stderr}")
1050
+ return (True, f"Deleted local and remote branch: {branch_name}")
1051
+
1052
+ return (True, f"Deleted local branch: {branch_name}")
1053
+
1054
+
1055
+ def cleanup_review_branches(
1056
+ repo_root: Path,
1057
+ package_id: Optional[str] = None,
1058
+ max_age_days: Optional[int] = None,
1059
+ only_merged: bool = True,
1060
+ delete_remote: bool = False,
1061
+ dry_run: bool = False,
1062
+ remote: str = 'origin'
1063
+ ) -> CleanupResult:
1064
+ """
1065
+ Delete review branches matching criteria.
1066
+
1067
+ REQ-tv-d00013: Service layer function for CLI --cleanup-reviews
1068
+ and --cleanup-stale-reviews.
1069
+
1070
+ Args:
1071
+ repo_root: Repository root path
1072
+ package_id: Optional filter by package ID
1073
+ max_age_days: Only delete branches older than N days (stale cleanup)
1074
+ only_merged: Only delete branches merged into main (default: True)
1075
+ delete_remote: Also delete remote tracking branches
1076
+ dry_run: If True, don't actually delete, just return what would be deleted
1077
+ remote: Remote name (default: 'origin')
1078
+
1079
+ Returns:
1080
+ CleanupResult with lists of deleted/skipped branches
1081
+
1082
+ Examples:
1083
+ >>> result = cleanup_review_branches(repo, only_merged=True)
1084
+ >>> print(f"Deleted {len(result.deleted_local)} branches")
1085
+
1086
+ >>> result = cleanup_review_branches(repo, max_age_days=30, dry_run=True)
1087
+ >>> print(f"Would delete {len(result.deleted_local)} branches")
1088
+ """
1089
+ result = CleanupResult(
1090
+ deleted_local=[],
1091
+ deleted_remote=[],
1092
+ skipped_current=[],
1093
+ skipped_unpushed=[],
1094
+ skipped_unmerged=[],
1095
+ errors=[]
1096
+ )
1097
+
1098
+ # Get branches to consider
1099
+ branches = list_review_branches_with_info(repo_root, package_id=package_id)
1100
+ current = get_current_branch(repo_root)
1101
+
1102
+ for branch in branches:
1103
+ # Skip current branch
1104
+ if branch.is_current or branch.name == current:
1105
+ result.skipped_current.append(branch.name)
1106
+ continue
1107
+
1108
+ # Skip if not old enough (when max_age_days is set)
1109
+ if max_age_days is not None and branch.age_days < max_age_days:
1110
+ continue
1111
+
1112
+ # Skip if only_merged and branch is not merged
1113
+ if only_merged and not branch.is_merged:
1114
+ result.skipped_unmerged.append(branch.name)
1115
+ continue
1116
+
1117
+ # Skip if has unpushed commits (safety)
1118
+ if has_unpushed_commits(repo_root, branch.name, remote):
1119
+ result.skipped_unpushed.append(branch.name)
1120
+ continue
1121
+
1122
+ # Delete the branch (or record for dry run)
1123
+ if dry_run:
1124
+ result.deleted_local.append(branch.name)
1125
+ if delete_remote and branch.has_remote:
1126
+ result.deleted_remote.append(branch.name)
1127
+ else:
1128
+ # Delete local branch
1129
+ delete_result = _run_git(repo_root, ['branch', '-d', branch.name])
1130
+ if delete_result.returncode == 0:
1131
+ result.deleted_local.append(branch.name)
1132
+
1133
+ # Delete remote if requested
1134
+ if delete_remote and branch.has_remote:
1135
+ remote_result = _run_git(
1136
+ repo_root, ['push', remote, '--delete', branch.name]
1137
+ )
1138
+ if remote_result.returncode == 0:
1139
+ result.deleted_remote.append(branch.name)
1140
+ else:
1141
+ result.errors.append(
1142
+ (branch.name, f"Failed to delete remote: {remote_result.stderr}")
1143
+ )
1144
+ else:
1145
+ result.errors.append(
1146
+ (branch.name, f"Failed to delete: {delete_result.stderr}")
1147
+ )
1148
+
1149
+ return result