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.
- elspais/__init__.py +1 -10
- elspais/{sponsors/__init__.py → associates.py} +102 -56
- elspais/cli.py +366 -69
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +118 -169
- elspais/commands/changed.py +12 -23
- elspais/commands/config_cmd.py +10 -13
- elspais/commands/edit.py +33 -13
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +161 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -115
- elspais/commands/init.py +99 -22
- elspais/commands/reformat_cmd.py +41 -433
- elspais/commands/rules_cmd.py +2 -2
- elspais/commands/trace.py +443 -324
- elspais/commands/validate.py +193 -411
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -2
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +45 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +1998 -244
- elspais/testing/__init__.py +3 -3
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +56 -51
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -179
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -346
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -639
- elspais/core/rules.py +0 -509
- elspais/mcp/context.py +0 -172
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -112
- elspais/reformat/hierarchy.py +0 -247
- elspais/reformat/line_breaks.py +0 -218
- elspais/reformat/prompts.py +0 -133
- elspais/reformat/transformer.py +0 -266
- elspais/trace_view/__init__.py +0 -55
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -334
- elspais/trace_view/generators/csv.py +0 -118
- elspais/trace_view/generators/markdown.py +0 -170
- elspais/trace_view/html/__init__.py +0 -33
- elspais/trace_view/html/generator.py +0 -1140
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -378
- elspais/trace_view/review/__init__.py +0 -63
- elspais/trace_view/review/branches.py +0 -1142
- elspais/trace_view/review/models.py +0 -1200
- elspais/trace_view/review/position.py +0 -591
- elspais/trace_view/review/server.py +0 -1032
- elspais/trace_view/review/status.py +0 -455
- elspais/trace_view/review/storage.py +0 -1343
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.2.dist-info/RECORD +0 -101
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {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
|