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