half-orm-dev 0.16.0a9__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.
- half_orm_dev/__init__.py +1 -0
- half_orm_dev/cli/__init__.py +9 -0
- half_orm_dev/cli/commands/__init__.py +56 -0
- half_orm_dev/cli/commands/apply.py +13 -0
- half_orm_dev/cli/commands/clone.py +102 -0
- half_orm_dev/cli/commands/init.py +331 -0
- half_orm_dev/cli/commands/new.py +15 -0
- half_orm_dev/cli/commands/patch.py +317 -0
- half_orm_dev/cli/commands/prepare.py +21 -0
- half_orm_dev/cli/commands/prepare_release.py +119 -0
- half_orm_dev/cli/commands/promote_to.py +127 -0
- half_orm_dev/cli/commands/release.py +344 -0
- half_orm_dev/cli/commands/restore.py +14 -0
- half_orm_dev/cli/commands/sync.py +13 -0
- half_orm_dev/cli/commands/todo.py +73 -0
- half_orm_dev/cli/commands/undo.py +17 -0
- half_orm_dev/cli/commands/update.py +73 -0
- half_orm_dev/cli/commands/upgrade.py +191 -0
- half_orm_dev/cli/main.py +103 -0
- half_orm_dev/cli_extension.py +38 -0
- half_orm_dev/database.py +1389 -0
- half_orm_dev/hgit.py +1025 -0
- half_orm_dev/hop.py +167 -0
- half_orm_dev/manifest.py +43 -0
- half_orm_dev/modules.py +456 -0
- half_orm_dev/patch.py +281 -0
- half_orm_dev/patch_manager.py +1694 -0
- half_orm_dev/patch_validator.py +335 -0
- half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +34 -0
- half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +2 -0
- half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +3 -0
- half_orm_dev/patches/log +2 -0
- half_orm_dev/patches/sql/half_orm_meta.sql +208 -0
- half_orm_dev/release_manager.py +2841 -0
- half_orm_dev/repo.py +1562 -0
- half_orm_dev/templates/.gitignore +15 -0
- half_orm_dev/templates/MANIFEST.in +1 -0
- half_orm_dev/templates/Pipfile +13 -0
- half_orm_dev/templates/README +25 -0
- half_orm_dev/templates/conftest_template +42 -0
- half_orm_dev/templates/init_module_template +10 -0
- half_orm_dev/templates/module_template_1 +12 -0
- half_orm_dev/templates/module_template_2 +6 -0
- half_orm_dev/templates/module_template_3 +3 -0
- half_orm_dev/templates/relation_test +23 -0
- half_orm_dev/templates/setup.py +81 -0
- half_orm_dev/templates/sql_adapter +9 -0
- half_orm_dev/templates/warning +12 -0
- half_orm_dev/utils.py +49 -0
- half_orm_dev/version.txt +1 -0
- half_orm_dev-0.16.0a9.dist-info/METADATA +935 -0
- half_orm_dev-0.16.0a9.dist-info/RECORD +58 -0
- half_orm_dev-0.16.0a9.dist-info/WHEEL +5 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/AUTHORS +3 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/LICENSE +14 -0
- half_orm_dev-0.16.0a9.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +329 -0
|
@@ -0,0 +1,1694 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PatchManager module for half-orm-dev
|
|
3
|
+
|
|
4
|
+
Manages Patches/patch-name/ directory structure, SQL/Python files,
|
|
5
|
+
and README.md generation for the patch-centric workflow.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Dict, Optional, Tuple, Any
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from git.exc import GitCommandError
|
|
19
|
+
|
|
20
|
+
from half_orm import utils
|
|
21
|
+
from .patch_validator import PatchValidator, PatchInfo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PatchManagerError(Exception):
|
|
25
|
+
"""Base exception for PatchManager operations."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PatchStructureError(PatchManagerError):
|
|
30
|
+
"""Raised when patch directory structure is invalid."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PatchFileError(PatchManagerError):
|
|
35
|
+
"""Raised when patch file operations fail."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PatchFile:
|
|
41
|
+
"""Information about a file within a patch directory."""
|
|
42
|
+
name: str
|
|
43
|
+
path: Path
|
|
44
|
+
extension: str
|
|
45
|
+
is_sql: bool
|
|
46
|
+
is_python: bool
|
|
47
|
+
exists: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class PatchStructure:
|
|
52
|
+
"""Complete structure information for a patch directory."""
|
|
53
|
+
patch_id: str
|
|
54
|
+
directory_path: Path
|
|
55
|
+
readme_path: Path
|
|
56
|
+
files: List[PatchFile]
|
|
57
|
+
is_valid: bool
|
|
58
|
+
validation_errors: List[str]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PatchManager:
|
|
62
|
+
"""
|
|
63
|
+
Manages patch directory structure and file operations.
|
|
64
|
+
|
|
65
|
+
Handles creation, validation, and management of Patches/patch-name/
|
|
66
|
+
directories following the patch-centric workflow specifications.
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
# Create new patch directory
|
|
70
|
+
patch_mgr = PatchManager(repo)
|
|
71
|
+
patch_mgr.create_patch_directory("456-user-authentication")
|
|
72
|
+
|
|
73
|
+
# Validate existing patch
|
|
74
|
+
structure = patch_mgr.get_patch_structure("456-user-authentication")
|
|
75
|
+
if not structure.is_valid:
|
|
76
|
+
print(f"Validation errors: {structure.validation_errors}")
|
|
77
|
+
|
|
78
|
+
# Apply patch files in order
|
|
79
|
+
patch_mgr.apply_patch_files("456-user-authentication")
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, repo):
|
|
83
|
+
"""
|
|
84
|
+
Initialize PatchManager manager.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
repo: Repository instance providing base_dir and configuration
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
PatchManagerError: If repository is invalid
|
|
91
|
+
"""
|
|
92
|
+
# Validate repository is not None
|
|
93
|
+
if repo is None:
|
|
94
|
+
raise PatchManagerError("Repository cannot be None")
|
|
95
|
+
|
|
96
|
+
# Validate repository has required attributes
|
|
97
|
+
required_attrs = ['base_dir', 'devel', 'name']
|
|
98
|
+
for attr in required_attrs:
|
|
99
|
+
if not hasattr(repo, attr):
|
|
100
|
+
raise PatchManagerError(f"Repository is invalid: missing '{attr}' attribute")
|
|
101
|
+
|
|
102
|
+
# Validate base directory exists and is a directory
|
|
103
|
+
if repo.base_dir is None:
|
|
104
|
+
raise PatchManagerError("Repository is invalid: base_dir cannot be None")
|
|
105
|
+
|
|
106
|
+
base_path = Path(repo.base_dir)
|
|
107
|
+
if not base_path.exists():
|
|
108
|
+
raise PatchManagerError(f"Base directory does not exist: {repo.base_dir}")
|
|
109
|
+
|
|
110
|
+
if not base_path.is_dir():
|
|
111
|
+
raise PatchManagerError(f"Base directory is not a directory: {repo.base_dir}")
|
|
112
|
+
|
|
113
|
+
# Store repository reference and paths
|
|
114
|
+
self._repo = repo
|
|
115
|
+
self._base_dir = str(repo.base_dir)
|
|
116
|
+
self._schema_patches_dir = base_path / "Patches"
|
|
117
|
+
|
|
118
|
+
# Store repository name
|
|
119
|
+
self._repo_name = repo.name
|
|
120
|
+
|
|
121
|
+
# Ensure Patches directory exists
|
|
122
|
+
try:
|
|
123
|
+
schema_exists = self._schema_patches_dir.exists()
|
|
124
|
+
except PermissionError:
|
|
125
|
+
raise PatchManagerError(f"Permission denied: cannot access Patches directory")
|
|
126
|
+
|
|
127
|
+
if not schema_exists:
|
|
128
|
+
try:
|
|
129
|
+
self._schema_patches_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
except PermissionError:
|
|
131
|
+
raise PatchManagerError(f"Permission denied: cannot create Patches directory")
|
|
132
|
+
except OSError as e:
|
|
133
|
+
raise PatchManagerError(f"Failed to create Patches directory: {e}")
|
|
134
|
+
|
|
135
|
+
# Validate Patches is a directory
|
|
136
|
+
try:
|
|
137
|
+
if not self._schema_patches_dir.is_dir():
|
|
138
|
+
raise PatchManagerError(f"Patches exists but is not a directory: {self._schema_patches_dir}")
|
|
139
|
+
except PermissionError:
|
|
140
|
+
raise PatchManagerError(f"Permission denied: cannot access Patches directory")
|
|
141
|
+
|
|
142
|
+
# Initialize PatchValidator
|
|
143
|
+
self._validator = PatchValidator()
|
|
144
|
+
|
|
145
|
+
def create_patch_directory(self, patch_id: str) -> Path:
|
|
146
|
+
"""
|
|
147
|
+
Create complete patch directory structure.
|
|
148
|
+
|
|
149
|
+
Creates Patches/patch-name/ directory with minimal README.md template
|
|
150
|
+
following the patch-centric workflow specifications.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
patch_id: Patch identifier (validated and normalized)
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Path to created patch directory
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
PatchManagerError: If directory creation fails
|
|
160
|
+
PatchStructureError: If patch directory already exists
|
|
161
|
+
|
|
162
|
+
Examples:
|
|
163
|
+
# Create with numeric ID
|
|
164
|
+
path = patch_mgr.create_patch_directory("456")
|
|
165
|
+
# Creates: Patches/456/ with README.md
|
|
166
|
+
|
|
167
|
+
# Create with full ID
|
|
168
|
+
path = patch_mgr.create_patch_directory("456-user-auth")
|
|
169
|
+
# Creates: Patches/456-user-auth/ with README.md
|
|
170
|
+
"""
|
|
171
|
+
# Validate patch ID format
|
|
172
|
+
try:
|
|
173
|
+
patch_info = self._validator.validate_patch_id(patch_id)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
raise PatchManagerError(f"Invalid patch ID: {e}")
|
|
176
|
+
|
|
177
|
+
# Get patch directory path
|
|
178
|
+
patch_path = self.get_patch_directory_path(patch_info.normalized_id)
|
|
179
|
+
|
|
180
|
+
# Check if directory already exists (handle permission errors)
|
|
181
|
+
try:
|
|
182
|
+
path_exists = patch_path.exists()
|
|
183
|
+
except PermissionError:
|
|
184
|
+
raise PatchManagerError(f"Permission denied: cannot access patch directory {patch_info.normalized_id}")
|
|
185
|
+
|
|
186
|
+
if path_exists:
|
|
187
|
+
raise PatchStructureError(f"Patch directory already exists: {patch_info.normalized_id}")
|
|
188
|
+
|
|
189
|
+
# Create the patch directory
|
|
190
|
+
try:
|
|
191
|
+
patch_path.mkdir(parents=True, exist_ok=False)
|
|
192
|
+
except PermissionError:
|
|
193
|
+
raise PatchManagerError(f"Permission denied: cannot create patch directory {patch_info.normalized_id}")
|
|
194
|
+
except OSError as e:
|
|
195
|
+
raise PatchManagerError(f"Failed to create patch directory {patch_info.normalized_id}: {e}")
|
|
196
|
+
|
|
197
|
+
# Create minimal README.md template
|
|
198
|
+
try:
|
|
199
|
+
readme_content = f"# Patch {patch_info.normalized_id}\n"
|
|
200
|
+
readme_path = patch_path / "README.md"
|
|
201
|
+
readme_path.write_text(readme_content, encoding='utf-8')
|
|
202
|
+
except Exception as e:
|
|
203
|
+
# If README creation fails, clean up the directory
|
|
204
|
+
try:
|
|
205
|
+
shutil.rmtree(patch_path)
|
|
206
|
+
except:
|
|
207
|
+
pass # Best effort cleanup
|
|
208
|
+
raise PatchManagerError(f"Failed to create README.md for patch {patch_info.normalized_id}: {e}")
|
|
209
|
+
|
|
210
|
+
return patch_path
|
|
211
|
+
|
|
212
|
+
def get_patch_structure(self, patch_id: str) -> PatchStructure:
|
|
213
|
+
"""
|
|
214
|
+
Analyze and validate patch directory structure.
|
|
215
|
+
|
|
216
|
+
Examines Patches/patch-name/ directory and returns complete
|
|
217
|
+
structure information including file validation and ordering.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
patch_id: Patch identifier to analyze
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
PatchStructure with complete analysis results
|
|
224
|
+
|
|
225
|
+
Examples:
|
|
226
|
+
structure = patch_mgr.get_patch_structure("456-user-auth")
|
|
227
|
+
|
|
228
|
+
if structure.is_valid:
|
|
229
|
+
print(f"Patch has {len(structure.files)} files")
|
|
230
|
+
for file in structure.files:
|
|
231
|
+
print(f" {file.order:02d}_{file.name}")
|
|
232
|
+
else:
|
|
233
|
+
print(f"Errors: {structure.validation_errors}")
|
|
234
|
+
"""
|
|
235
|
+
# Get patch directory path
|
|
236
|
+
patch_path = self.get_patch_directory_path(patch_id)
|
|
237
|
+
readme_path = patch_path / "README.md"
|
|
238
|
+
|
|
239
|
+
# Use validate_patch_structure for basic validation
|
|
240
|
+
is_valid, validation_errors = self.validate_patch_structure(patch_id)
|
|
241
|
+
|
|
242
|
+
# If basic validation fails, return structure with errors
|
|
243
|
+
if not is_valid:
|
|
244
|
+
return PatchStructure(
|
|
245
|
+
patch_id=patch_id,
|
|
246
|
+
directory_path=patch_path,
|
|
247
|
+
readme_path=readme_path,
|
|
248
|
+
files=[],
|
|
249
|
+
is_valid=False,
|
|
250
|
+
validation_errors=validation_errors
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Analyze files in the patch directory
|
|
254
|
+
patch_files = []
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
# Get all files in lexicographic order (excluding README.md)
|
|
258
|
+
all_items = sorted(patch_path.iterdir(), key=lambda x: x.name.lower())
|
|
259
|
+
executable_files = [item for item in all_items if item.is_file() and item.name != "README.md"]
|
|
260
|
+
|
|
261
|
+
for item in executable_files:
|
|
262
|
+
# Create PatchFile object
|
|
263
|
+
extension = item.suffix.lower().lstrip('.')
|
|
264
|
+
is_sql = extension == 'sql'
|
|
265
|
+
is_python = extension in ['py', 'python']
|
|
266
|
+
|
|
267
|
+
patch_file = PatchFile(
|
|
268
|
+
name=item.name,
|
|
269
|
+
path=item,
|
|
270
|
+
extension=extension,
|
|
271
|
+
is_sql=is_sql,
|
|
272
|
+
is_python=is_python,
|
|
273
|
+
exists=True
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
patch_files.append(patch_file)
|
|
277
|
+
|
|
278
|
+
except PermissionError:
|
|
279
|
+
# If we can't read directory contents, mark as invalid
|
|
280
|
+
validation_errors.append(f"Permission denied: cannot read patch directory contents")
|
|
281
|
+
is_valid = False
|
|
282
|
+
|
|
283
|
+
# Create and return PatchStructure
|
|
284
|
+
return PatchStructure(
|
|
285
|
+
patch_id=patch_id,
|
|
286
|
+
directory_path=patch_path,
|
|
287
|
+
readme_path=readme_path,
|
|
288
|
+
files=patch_files,
|
|
289
|
+
is_valid=is_valid,
|
|
290
|
+
validation_errors=validation_errors
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def list_patch_files(self, patch_id: str, file_type: Optional[str] = None) -> List[PatchFile]:
|
|
294
|
+
"""
|
|
295
|
+
List all files in patch directory with ordering information.
|
|
296
|
+
|
|
297
|
+
Returns files in lexicographic order suitable for sequential application.
|
|
298
|
+
Supports filtering by file type (sql, python, or None for all).
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
patch_id: Patch identifier
|
|
302
|
+
file_type: Filter by 'sql', 'python', or None for all files
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
List of PatchFile objects in application order
|
|
306
|
+
|
|
307
|
+
Examples:
|
|
308
|
+
# All files in order
|
|
309
|
+
files = patch_mgr.list_patch_files("456-user-auth")
|
|
310
|
+
|
|
311
|
+
# SQL files only
|
|
312
|
+
sql_files = patch_mgr.list_patch_files("456-user-auth", "sql")
|
|
313
|
+
|
|
314
|
+
# Files are returned in lexicographic order:
|
|
315
|
+
# 01_create_users.sql, 02_add_indexes.sql, 03_permissions.py
|
|
316
|
+
"""
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
def validate_patch_structure(self, patch_id: str) -> Tuple[bool, List[str]]:
|
|
320
|
+
"""
|
|
321
|
+
Validate patch directory structure and contents.
|
|
322
|
+
|
|
323
|
+
Performs minimal validation following KISS principle:
|
|
324
|
+
- Directory exists and accessible
|
|
325
|
+
|
|
326
|
+
Developers have full flexibility for patch content and structure.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
patch_id: Patch identifier to validate
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Tuple of (is_valid, list_of_errors)
|
|
333
|
+
|
|
334
|
+
Examples:
|
|
335
|
+
is_valid, errors = patch_mgr.validate_patch_structure("456-user-auth")
|
|
336
|
+
|
|
337
|
+
if not is_valid:
|
|
338
|
+
for error in errors:
|
|
339
|
+
print(f"Validation error: {error}")
|
|
340
|
+
"""
|
|
341
|
+
errors = []
|
|
342
|
+
|
|
343
|
+
# Get patch directory path
|
|
344
|
+
patch_path = self.get_patch_directory_path(patch_id)
|
|
345
|
+
|
|
346
|
+
# Minimal validation: directory exists and is accessible
|
|
347
|
+
try:
|
|
348
|
+
if not patch_path.exists():
|
|
349
|
+
errors.append(f"Patch directory does not exist: {patch_id}")
|
|
350
|
+
elif not patch_path.is_dir():
|
|
351
|
+
errors.append(f"Path is not a directory: {patch_path}")
|
|
352
|
+
except PermissionError:
|
|
353
|
+
errors.append(f"Permission denied: cannot access patch directory {patch_id}")
|
|
354
|
+
|
|
355
|
+
# Return validation results
|
|
356
|
+
is_valid = len(errors) == 0
|
|
357
|
+
return is_valid, errors
|
|
358
|
+
|
|
359
|
+
def generate_readme_content(self, patch_info: PatchInfo, description_hint: Optional[str] = None) -> str:
|
|
360
|
+
"""
|
|
361
|
+
Generate README.md content for patch directory.
|
|
362
|
+
|
|
363
|
+
Creates comprehensive README.md with:
|
|
364
|
+
- Patch identification and purpose
|
|
365
|
+
- File execution order documentation
|
|
366
|
+
- Integration instructions
|
|
367
|
+
- Template placeholders for manual completion
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
patch_info: Validated patch information
|
|
371
|
+
description_hint: Optional description for content generation
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Complete README.md content as string
|
|
375
|
+
|
|
376
|
+
Examples:
|
|
377
|
+
patch_info = validator.validate_patch_id("456-user-auth")
|
|
378
|
+
content = patch_mgr.generate_readme_content(
|
|
379
|
+
patch_info,
|
|
380
|
+
"User authentication and session management"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Content includes:
|
|
384
|
+
# # Patch 456: User Authentication
|
|
385
|
+
# ## Purpose
|
|
386
|
+
# User authentication and session management
|
|
387
|
+
# ## Files
|
|
388
|
+
# - 01_create_users.sql: Create users table
|
|
389
|
+
# - 02_add_indexes.sql: Add performance indexes
|
|
390
|
+
"""
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
def create_readme_file(self, patch_id: str, description_hint: Optional[str] = None) -> Path:
|
|
394
|
+
"""
|
|
395
|
+
Create README.md file in patch directory.
|
|
396
|
+
|
|
397
|
+
Generates and writes comprehensive README.md file for the patch
|
|
398
|
+
using templates and patch information.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
patch_id: Patch identifier (validated)
|
|
402
|
+
description_hint: Optional description for README content
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Path to created README.md file
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
PatchFileError: If README creation fails
|
|
409
|
+
|
|
410
|
+
Examples:
|
|
411
|
+
readme_path = patch_mgr.create_readme_file("456-user-auth")
|
|
412
|
+
# Creates: Patches/456-user-auth/README.md
|
|
413
|
+
"""
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
def add_patch_file(self, patch_id: str, filename: str, content: str = "") -> Path:
|
|
417
|
+
"""
|
|
418
|
+
Add new file to patch directory.
|
|
419
|
+
|
|
420
|
+
Creates new SQL or Python file in patch directory with optional
|
|
421
|
+
initial content. Validates filename follows conventions.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
patch_id: Patch identifier
|
|
425
|
+
filename: Name of file to create (must include .sql or .py extension)
|
|
426
|
+
content: Optional initial content for file
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
Path to created file
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
PatchFileError: If file creation fails or filename invalid
|
|
433
|
+
|
|
434
|
+
Examples:
|
|
435
|
+
# Add SQL file
|
|
436
|
+
sql_path = patch_mgr.add_patch_file(
|
|
437
|
+
"456-user-auth",
|
|
438
|
+
"01_create_users.sql",
|
|
439
|
+
"CREATE TABLE users (id SERIAL PRIMARY KEY);"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Add Python file
|
|
443
|
+
py_path = patch_mgr.add_patch_file(
|
|
444
|
+
"456-user-auth",
|
|
445
|
+
"02_update_permissions.py",
|
|
446
|
+
"# Update user permissions"
|
|
447
|
+
)
|
|
448
|
+
"""
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
def remove_patch_file(self, patch_id: str, filename: str) -> bool:
|
|
452
|
+
"""
|
|
453
|
+
Remove file from patch directory.
|
|
454
|
+
|
|
455
|
+
Safely removes specified file from patch directory with validation.
|
|
456
|
+
Does not remove README.md (protected file).
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
patch_id: Patch identifier
|
|
460
|
+
filename: Name of file to remove
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
True if file was removed, False if file didn't exist
|
|
464
|
+
|
|
465
|
+
Raises:
|
|
466
|
+
PatchFileError: If removal fails or file is protected
|
|
467
|
+
|
|
468
|
+
Examples:
|
|
469
|
+
# Remove SQL file
|
|
470
|
+
removed = patch_mgr.remove_patch_file("456-user-auth", "old_script.sql")
|
|
471
|
+
|
|
472
|
+
# Cannot remove README.md
|
|
473
|
+
try:
|
|
474
|
+
patch_mgr.remove_patch_file("456-user-auth", "README.md")
|
|
475
|
+
except PatchFileError as e:
|
|
476
|
+
print(f"Cannot remove protected file: {e}")
|
|
477
|
+
"""
|
|
478
|
+
pass
|
|
479
|
+
|
|
480
|
+
def apply_patch_complete_workflow(self, patch_id: str) -> dict:
|
|
481
|
+
"""
|
|
482
|
+
Apply patch with full release context.
|
|
483
|
+
|
|
484
|
+
Workflow:
|
|
485
|
+
1. Restore DB from production baseline (model/schema.sql)
|
|
486
|
+
2. Apply all release patches in order (RC1, RC2, ..., stage)
|
|
487
|
+
3. If current patch is in release, apply it in correct order
|
|
488
|
+
4. If current patch is NOT in release, apply it at the end
|
|
489
|
+
5. Generate Python code
|
|
490
|
+
|
|
491
|
+
Examples:
|
|
492
|
+
# Release context: [123, 456, 789, 234]
|
|
493
|
+
# Current patch: 789 (already in release)
|
|
494
|
+
|
|
495
|
+
apply_patch_complete_workflow("789")
|
|
496
|
+
# Execution:
|
|
497
|
+
# 1. Restore DB (1.3.5)
|
|
498
|
+
# 2. Apply 123
|
|
499
|
+
# 3. Apply 456
|
|
500
|
+
# 4. Apply 789 ← In correct order
|
|
501
|
+
# 5. Apply 234
|
|
502
|
+
# 6. Generate code
|
|
503
|
+
|
|
504
|
+
# Current patch: 999 (NOT in release)
|
|
505
|
+
apply_patch_complete_workflow("999")
|
|
506
|
+
# Execution:
|
|
507
|
+
# 1. Restore DB (1.3.5)
|
|
508
|
+
# 2. Apply 123
|
|
509
|
+
# 3. Apply 456
|
|
510
|
+
# 4. Apply 789
|
|
511
|
+
# 5. Apply 234
|
|
512
|
+
# 6. Apply 999 ← At the end
|
|
513
|
+
# 7. Generate code
|
|
514
|
+
"""
|
|
515
|
+
from half_orm_dev import modules
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
# Étape 1: Restauration DB
|
|
519
|
+
self._repo.restore_database_from_schema()
|
|
520
|
+
|
|
521
|
+
# Étape 2: Récupérer contexte release complet
|
|
522
|
+
release_patches = self._repo.release_manager.get_all_release_context_patches()
|
|
523
|
+
|
|
524
|
+
applied_release_files = []
|
|
525
|
+
applied_current_files = []
|
|
526
|
+
patch_was_in_release = False
|
|
527
|
+
|
|
528
|
+
# Étape 3: Appliquer patches
|
|
529
|
+
for patch in release_patches:
|
|
530
|
+
if patch == patch_id:
|
|
531
|
+
patch_was_in_release = True
|
|
532
|
+
files = self.apply_patch_files(patch, self._repo.model)
|
|
533
|
+
applied_release_files.extend(files)
|
|
534
|
+
|
|
535
|
+
# Étape 4: Si patch courant pas dans release, l'appliquer maintenant
|
|
536
|
+
if not patch_was_in_release:
|
|
537
|
+
files = self.apply_patch_files(patch_id, self._repo.model)
|
|
538
|
+
applied_current_files = files
|
|
539
|
+
|
|
540
|
+
# Étape 5: Génération code Python
|
|
541
|
+
# Track generated files
|
|
542
|
+
package_dir = Path(self._base_dir) / self._repo_name
|
|
543
|
+
files_before = set()
|
|
544
|
+
if package_dir.exists():
|
|
545
|
+
files_before = set(package_dir.rglob('*.py'))
|
|
546
|
+
|
|
547
|
+
modules.generate(self._repo)
|
|
548
|
+
|
|
549
|
+
files_after = set()
|
|
550
|
+
if package_dir.exists():
|
|
551
|
+
files_after = set(package_dir.rglob('*.py'))
|
|
552
|
+
|
|
553
|
+
generated_files = [str(f.relative_to(self._base_dir)) for f in files_after]
|
|
554
|
+
|
|
555
|
+
# Étape 6: Retour succès
|
|
556
|
+
return {
|
|
557
|
+
'patch_id': patch_id,
|
|
558
|
+
'release_patches': [p for p in release_patches if p != patch_id],
|
|
559
|
+
'applied_release_files': applied_release_files,
|
|
560
|
+
'applied_current_files': applied_current_files,
|
|
561
|
+
'patch_was_in_release': patch_was_in_release,
|
|
562
|
+
'generated_files': generated_files,
|
|
563
|
+
'status': 'success',
|
|
564
|
+
'error': None
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
except PatchManagerError:
|
|
568
|
+
self._repo.restore_database_from_schema()
|
|
569
|
+
raise
|
|
570
|
+
|
|
571
|
+
except Exception as e:
|
|
572
|
+
self._repo.restore_database_from_schema()
|
|
573
|
+
raise PatchManagerError(
|
|
574
|
+
f"Apply patch workflow failed for {patch_id}: {e}"
|
|
575
|
+
) from e
|
|
576
|
+
|
|
577
|
+
def apply_patch_files(self, patch_id: str, database_model) -> List[str]:
|
|
578
|
+
"""
|
|
579
|
+
Apply all patch files in correct order.
|
|
580
|
+
|
|
581
|
+
Executes SQL files and Python scripts from patch directory in
|
|
582
|
+
lexicographic order. Integrates with halfORM modules.py for
|
|
583
|
+
code generation after schema changes.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
patch_id: Patch identifier to apply
|
|
587
|
+
database_model: halfORM Model instance for SQL execution
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
List of applied filenames in execution order
|
|
591
|
+
|
|
592
|
+
Raises:
|
|
593
|
+
PatchManagerError: If patch application fails
|
|
594
|
+
|
|
595
|
+
Examples:
|
|
596
|
+
applied_files = patch_mgr.apply_patch_files("456-user-auth", repo.model)
|
|
597
|
+
|
|
598
|
+
# Returns: ["01_create_users.sql", "02_add_indexes.sql", "03_permissions.py"]
|
|
599
|
+
# After execution:
|
|
600
|
+
# - Schema changes applied to database
|
|
601
|
+
# - halfORM code regenerated via modules.py integration
|
|
602
|
+
# - Business logic stubs created if needed
|
|
603
|
+
"""
|
|
604
|
+
applied_files = []
|
|
605
|
+
|
|
606
|
+
# Get patch structure
|
|
607
|
+
structure = self.get_patch_structure(patch_id)
|
|
608
|
+
|
|
609
|
+
# Validate patch is valid
|
|
610
|
+
if not structure.is_valid:
|
|
611
|
+
error_msg = "; ".join(structure.validation_errors)
|
|
612
|
+
raise PatchManagerError(f"Cannot apply invalid patch {patch_id}: {error_msg}")
|
|
613
|
+
|
|
614
|
+
# Apply files in lexicographic order
|
|
615
|
+
for patch_file in structure.files:
|
|
616
|
+
if patch_file.is_sql:
|
|
617
|
+
self._execute_sql_file(patch_file.path, database_model)
|
|
618
|
+
applied_files.append(patch_file.name)
|
|
619
|
+
elif patch_file.is_python:
|
|
620
|
+
self._execute_python_file(patch_file.path)
|
|
621
|
+
applied_files.append(patch_file.name)
|
|
622
|
+
# Other file types are ignored (not executed)
|
|
623
|
+
|
|
624
|
+
return applied_files
|
|
625
|
+
|
|
626
|
+
def get_patch_directory_path(self, patch_id: str) -> Path:
|
|
627
|
+
"""
|
|
628
|
+
Get path to patch directory.
|
|
629
|
+
|
|
630
|
+
Returns Path object for Patches/patch-name/ directory.
|
|
631
|
+
Does not validate existence - use get_patch_structure() for validation.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
patch_id: Patch identifier
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
Path object for patch directory
|
|
638
|
+
|
|
639
|
+
Examples:
|
|
640
|
+
path = patch_mgr.get_patch_directory_path("456-user-auth")
|
|
641
|
+
# Returns: Path("Patches/456-user-auth")
|
|
642
|
+
|
|
643
|
+
# Check if exists
|
|
644
|
+
if path.exists():
|
|
645
|
+
print(f"Patch directory exists at {path}")
|
|
646
|
+
"""
|
|
647
|
+
# Normalize patch_id by stripping whitespace
|
|
648
|
+
normalized_patch_id = patch_id.strip() if patch_id else ""
|
|
649
|
+
|
|
650
|
+
# Return path without validation (as documented)
|
|
651
|
+
return self._schema_patches_dir / normalized_patch_id
|
|
652
|
+
|
|
653
|
+
def list_all_patches(self) -> List[str]:
|
|
654
|
+
"""
|
|
655
|
+
List all existing patch directories.
|
|
656
|
+
|
|
657
|
+
Scans Patches/ directory and returns all valid patch identifiers.
|
|
658
|
+
Only returns directories that pass basic validation.
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
List of patch identifiers
|
|
662
|
+
|
|
663
|
+
Examples:
|
|
664
|
+
patches = patch_mgr.list_all_patches()
|
|
665
|
+
# Returns: ["456-user-auth", "789-security-fix", "234-performance"]
|
|
666
|
+
|
|
667
|
+
for patch_id in patches:
|
|
668
|
+
structure = patch_mgr.get_patch_structure(patch_id)
|
|
669
|
+
print(f"{patch_id}: {'valid' if structure.is_valid else 'invalid'}")
|
|
670
|
+
"""
|
|
671
|
+
valid_patches = []
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
# Scan Patches directory
|
|
675
|
+
if not self._schema_patches_dir.exists():
|
|
676
|
+
return []
|
|
677
|
+
|
|
678
|
+
for item in self._schema_patches_dir.iterdir():
|
|
679
|
+
# Skip files, only process directories
|
|
680
|
+
if not item.is_dir():
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
# Basic patch ID validation - must start with number
|
|
684
|
+
# This excludes hidden directories, __pycache__, etc.
|
|
685
|
+
if not item.name or not item.name[0].isdigit():
|
|
686
|
+
continue
|
|
687
|
+
|
|
688
|
+
# Check for required README.md file
|
|
689
|
+
readme_path = item / "README.md"
|
|
690
|
+
try:
|
|
691
|
+
if readme_path.exists() and readme_path.is_file():
|
|
692
|
+
valid_patches.append(item.name)
|
|
693
|
+
except PermissionError:
|
|
694
|
+
# Skip directories we can't read
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
except PermissionError:
|
|
698
|
+
# If we can't read Patches directory, return empty list
|
|
699
|
+
return []
|
|
700
|
+
except OSError:
|
|
701
|
+
# Handle other filesystem errors
|
|
702
|
+
return []
|
|
703
|
+
|
|
704
|
+
# Sort patches by numeric value of ticket number
|
|
705
|
+
def sort_key(patch_id):
|
|
706
|
+
try:
|
|
707
|
+
# Extract number part for sorting
|
|
708
|
+
if '-' in patch_id:
|
|
709
|
+
number_part = patch_id.split('-', 1)[0]
|
|
710
|
+
else:
|
|
711
|
+
number_part = patch_id
|
|
712
|
+
return int(number_part)
|
|
713
|
+
except ValueError:
|
|
714
|
+
# Fallback to string sort if not numeric
|
|
715
|
+
return float('inf')
|
|
716
|
+
|
|
717
|
+
valid_patches.sort(key=sort_key)
|
|
718
|
+
return valid_patches
|
|
719
|
+
|
|
720
|
+
def delete_patch_directory(self, patch_id: str, confirm: bool = False) -> bool:
|
|
721
|
+
"""
|
|
722
|
+
Delete entire patch directory.
|
|
723
|
+
|
|
724
|
+
Removes Patches/patch-name/ directory and all contents.
|
|
725
|
+
Requires explicit confirmation to prevent accidental deletion.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
patch_id: Patch identifier to delete
|
|
729
|
+
confirm: Must be True to actually delete (safety measure)
|
|
730
|
+
|
|
731
|
+
Returns:
|
|
732
|
+
True if directory was deleted, False if confirm=False
|
|
733
|
+
|
|
734
|
+
Raises:
|
|
735
|
+
PatchManagerError: If deletion fails
|
|
736
|
+
|
|
737
|
+
Examples:
|
|
738
|
+
# Safe call - returns False without deleting
|
|
739
|
+
deleted = patch_mgr.delete_patch_directory("456-user-auth")
|
|
740
|
+
|
|
741
|
+
# Actually delete
|
|
742
|
+
deleted = patch_mgr.delete_patch_directory("456-user-auth", confirm=True)
|
|
743
|
+
if deleted:
|
|
744
|
+
print("Patch directory deleted successfully")
|
|
745
|
+
"""
|
|
746
|
+
# Safety check - require explicit confirmation
|
|
747
|
+
if not confirm:
|
|
748
|
+
return False
|
|
749
|
+
|
|
750
|
+
# Validate patch ID format - require full patch name for safety
|
|
751
|
+
if not patch_id or not patch_id.strip():
|
|
752
|
+
raise PatchManagerError("Invalid patch ID: cannot be empty")
|
|
753
|
+
|
|
754
|
+
patch_id = patch_id.strip()
|
|
755
|
+
|
|
756
|
+
# Validate patch ID using PatchValidator for complete validation
|
|
757
|
+
try:
|
|
758
|
+
patch_info = self._validator.validate_patch_id(patch_id)
|
|
759
|
+
except Exception as e:
|
|
760
|
+
raise PatchManagerError(f"Invalid patch ID format: {e}")
|
|
761
|
+
|
|
762
|
+
# For deletion safety, require full patch name (not just numeric ID)
|
|
763
|
+
if patch_info.is_numeric_only:
|
|
764
|
+
raise PatchManagerError(
|
|
765
|
+
f"For safety, deletion requires full patch name, not just ID '{patch_id}'. "
|
|
766
|
+
f"Use complete format like '{patch_id}-description'"
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
# Get patch directory path
|
|
770
|
+
patch_path = self.get_patch_directory_path(patch_id)
|
|
771
|
+
|
|
772
|
+
# Check if directory exists (handle permission errors)
|
|
773
|
+
try:
|
|
774
|
+
path_exists = patch_path.exists()
|
|
775
|
+
except PermissionError:
|
|
776
|
+
raise PatchManagerError(f"Permission denied: cannot access patch directory {patch_id}")
|
|
777
|
+
|
|
778
|
+
if not path_exists:
|
|
779
|
+
raise PatchManagerError(f"Patch directory does not exist: {patch_id}")
|
|
780
|
+
|
|
781
|
+
# Verify it's actually a directory, not a file (handle permission errors)
|
|
782
|
+
try:
|
|
783
|
+
is_directory = patch_path.is_dir()
|
|
784
|
+
except PermissionError:
|
|
785
|
+
raise PatchManagerError(f"Permission denied: cannot access patch directory {patch_id}")
|
|
786
|
+
|
|
787
|
+
if not is_directory:
|
|
788
|
+
raise PatchManagerError(f"Path exists but is not a directory: {patch_path}")
|
|
789
|
+
|
|
790
|
+
# Delete the directory and all contents
|
|
791
|
+
try:
|
|
792
|
+
shutil.rmtree(patch_path)
|
|
793
|
+
return True
|
|
794
|
+
|
|
795
|
+
except PermissionError as e:
|
|
796
|
+
raise PatchManagerError(f"Permission denied: cannot delete {patch_path}") from e
|
|
797
|
+
except OSError as e:
|
|
798
|
+
raise PatchManagerError(f"Failed to delete patch directory {patch_path}: {e}") from e
|
|
799
|
+
|
|
800
|
+
def _validate_filename(self, filename: str) -> Tuple[bool, str]:
|
|
801
|
+
"""
|
|
802
|
+
Validate patch filename follows conventions.
|
|
803
|
+
|
|
804
|
+
Internal method to validate SQL/Python filenames follow naming
|
|
805
|
+
conventions for proper lexicographic ordering.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
filename: Filename to validate
|
|
809
|
+
|
|
810
|
+
Returns:
|
|
811
|
+
Tuple of (is_valid, error_message_if_invalid)
|
|
812
|
+
"""
|
|
813
|
+
pass
|
|
814
|
+
|
|
815
|
+
def _execute_sql_file(self, file_path: Path, database_model) -> None:
|
|
816
|
+
"""
|
|
817
|
+
Execute SQL file against database.
|
|
818
|
+
|
|
819
|
+
Internal method to safely execute SQL files with error handling
|
|
820
|
+
using halfORM Model.execute_query().
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
file_path: Path to SQL file
|
|
824
|
+
database_model: halfORM Model instance
|
|
825
|
+
|
|
826
|
+
Raises:
|
|
827
|
+
PatchManagerError: If SQL execution fails
|
|
828
|
+
"""
|
|
829
|
+
try:
|
|
830
|
+
# Read SQL content
|
|
831
|
+
sql_content = file_path.read_text(encoding='utf-8')
|
|
832
|
+
|
|
833
|
+
# Skip empty files
|
|
834
|
+
if not sql_content.strip():
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
# Execute SQL using halfORM model (same as patch.py line 144)
|
|
838
|
+
database_model.execute_query(sql_content)
|
|
839
|
+
|
|
840
|
+
except Exception as e:
|
|
841
|
+
raise PatchManagerError(f"SQL execution failed in {file_path.name}: {e}") from e
|
|
842
|
+
|
|
843
|
+
def _execute_python_file(self, file_path: Path) -> None:
|
|
844
|
+
"""
|
|
845
|
+
Execute Python script file.
|
|
846
|
+
|
|
847
|
+
Internal method to safely execute Python scripts with proper
|
|
848
|
+
environment setup and error handling.
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
file_path: Path to Python file
|
|
852
|
+
|
|
853
|
+
Raises:
|
|
854
|
+
PatchManagerError: If Python execution fails
|
|
855
|
+
"""
|
|
856
|
+
try:
|
|
857
|
+
# Setup Python execution environment
|
|
858
|
+
import subprocess
|
|
859
|
+
import sys
|
|
860
|
+
|
|
861
|
+
# Execute Python script as subprocess
|
|
862
|
+
result = subprocess.run(
|
|
863
|
+
[sys.executable, str(file_path)],
|
|
864
|
+
cwd=file_path.parent,
|
|
865
|
+
capture_output=True,
|
|
866
|
+
text=True,
|
|
867
|
+
check=True
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
# Log output if any (could be enhanced with proper logging)
|
|
871
|
+
if result.stdout.strip():
|
|
872
|
+
print(f"Python output from {file_path.name}: {result.stdout.strip()}")
|
|
873
|
+
|
|
874
|
+
except subprocess.CalledProcessError as e:
|
|
875
|
+
error_msg = f"Python execution failed in {file_path.name}"
|
|
876
|
+
if e.stderr:
|
|
877
|
+
error_msg += f": {e.stderr.strip()}"
|
|
878
|
+
raise PatchManagerError(error_msg) from e
|
|
879
|
+
except Exception as e:
|
|
880
|
+
raise PatchManagerError(f"Failed to execute Python file {file_path.name}: {e}") from e
|
|
881
|
+
|
|
882
|
+
def _fetch_from_remote(self) -> None:
|
|
883
|
+
"""
|
|
884
|
+
Fetch all references from remote before patch creation.
|
|
885
|
+
|
|
886
|
+
Updates local knowledge of remote state including:
|
|
887
|
+
- Remote branches (ho-prod, ho-patch/*)
|
|
888
|
+
- Remote tags (ho-patch/{number} reservation tags)
|
|
889
|
+
- All other remote references
|
|
890
|
+
|
|
891
|
+
This ensures patch creation is based on the latest remote state and
|
|
892
|
+
prevents conflicts with recently created patches by other developers.
|
|
893
|
+
|
|
894
|
+
Called early in create_patch() workflow to synchronize with remote
|
|
895
|
+
before checking patch number availability.
|
|
896
|
+
|
|
897
|
+
Raises:
|
|
898
|
+
PatchManagerError: If fetch fails (network, auth, etc.)
|
|
899
|
+
|
|
900
|
+
Examples:
|
|
901
|
+
self._fetch_from_remote()
|
|
902
|
+
# Local git now has up-to-date view of remote
|
|
903
|
+
# Can accurately check tag/branch availability
|
|
904
|
+
"""
|
|
905
|
+
try:
|
|
906
|
+
self._repo.hgit.fetch_from_origin()
|
|
907
|
+
except Exception as e:
|
|
908
|
+
raise PatchManagerError(
|
|
909
|
+
f"Failed to fetch from remote: {e}\n"
|
|
910
|
+
f"Cannot synchronize with remote repository.\n"
|
|
911
|
+
f"Check network connection and remote access."
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
def _commit_patch_directory(self, patch_id: str, description: Optional[str] = None) -> None:
|
|
915
|
+
"""
|
|
916
|
+
Commit patch directory to git repository.
|
|
917
|
+
|
|
918
|
+
Creates a commit containing the Patches/patch-id/ directory and README.md.
|
|
919
|
+
This commit becomes the target for the reservation tag, ensuring the tag
|
|
920
|
+
points to a repository state that includes the patch directory structure.
|
|
921
|
+
|
|
922
|
+
Args:
|
|
923
|
+
patch_id: Patch identifier (e.g., "456-user-auth")
|
|
924
|
+
description: Optional description included in commit message
|
|
925
|
+
|
|
926
|
+
Raises:
|
|
927
|
+
PatchManagerError: If git operations fail
|
|
928
|
+
|
|
929
|
+
Examples:
|
|
930
|
+
self._commit_patch_directory("456-user-auth")
|
|
931
|
+
# Creates commit: "Add Patches/456-user-auth directory"
|
|
932
|
+
|
|
933
|
+
self._commit_patch_directory("456-user-auth", "Add user authentication")
|
|
934
|
+
# Creates commit: "Add Patches/456-user-auth directory - Add user authentication"
|
|
935
|
+
"""
|
|
936
|
+
try:
|
|
937
|
+
# Add the patch directory to git
|
|
938
|
+
patch_path = self.get_patch_directory_path(patch_id)
|
|
939
|
+
self._repo.hgit.add(str(patch_path))
|
|
940
|
+
|
|
941
|
+
# Create commit message
|
|
942
|
+
if description:
|
|
943
|
+
commit_message = f"Add Patches/{patch_id} directory - {description}"
|
|
944
|
+
else:
|
|
945
|
+
commit_message = f"Add Patches/{patch_id} directory"
|
|
946
|
+
|
|
947
|
+
# Commit the changes
|
|
948
|
+
self._repo.hgit.commit('-m', commit_message)
|
|
949
|
+
|
|
950
|
+
except Exception as e:
|
|
951
|
+
raise PatchManagerError(
|
|
952
|
+
f"Failed to commit patch directory {patch_id}: {e}"
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
def _create_local_tag(self, patch_id: str, description: Optional[str] = None) -> None:
|
|
956
|
+
"""
|
|
957
|
+
Create local git tag without pushing to remote.
|
|
958
|
+
|
|
959
|
+
Creates tag ho-patch/{number} pointing to current HEAD (which should be
|
|
960
|
+
the commit containing the Patches/ directory). Tag is created locally only;
|
|
961
|
+
push happens separately as the atomic reservation operation.
|
|
962
|
+
|
|
963
|
+
Args:
|
|
964
|
+
patch_id: Patch identifier (e.g., "456-user-auth")
|
|
965
|
+
description: Optional description for tag message
|
|
966
|
+
|
|
967
|
+
Raises:
|
|
968
|
+
PatchManagerError: If tag creation fails
|
|
969
|
+
|
|
970
|
+
Examples:
|
|
971
|
+
self._create_local_tag("456-user-auth")
|
|
972
|
+
# Creates local tag: ho-patch/456 with message "Patch 456 reserved"
|
|
973
|
+
|
|
974
|
+
self._create_local_tag("456-user-auth", "Add user authentication")
|
|
975
|
+
# Creates local tag: ho-patch/456 with message "Patch 456: Add user authentication"
|
|
976
|
+
"""
|
|
977
|
+
# Extract patch number
|
|
978
|
+
patch_number = patch_id.split('-')[0]
|
|
979
|
+
tag_name = f"ho-patch/{patch_number}"
|
|
980
|
+
|
|
981
|
+
# Create tag message
|
|
982
|
+
if description:
|
|
983
|
+
tag_message = f"Patch {patch_number}: {description}"
|
|
984
|
+
else:
|
|
985
|
+
tag_message = f"Patch {patch_number} reserved"
|
|
986
|
+
|
|
987
|
+
try:
|
|
988
|
+
# Create tag locally (no push)
|
|
989
|
+
self._repo.hgit.create_tag(tag_name, tag_message)
|
|
990
|
+
except Exception as e:
|
|
991
|
+
raise PatchManagerError(
|
|
992
|
+
f"Failed to create local tag {tag_name}: {e}"
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
def _push_tag_to_reserve_number(self, patch_id: str) -> None:
|
|
996
|
+
"""
|
|
997
|
+
Push tag to remote for atomic global reservation.
|
|
998
|
+
|
|
999
|
+
This is the point of no return in the patch creation workflow. Once the
|
|
1000
|
+
tag is successfully pushed, the patch number is reserved globally and
|
|
1001
|
+
cannot be rolled back. This must happen BEFORE pushing the branch to
|
|
1002
|
+
prevent race conditions between developers.
|
|
1003
|
+
|
|
1004
|
+
Tag-first strategy prevents race conditions:
|
|
1005
|
+
- Developer A pushes tag ho-patch/456 → reservation complete
|
|
1006
|
+
- Developer B fetches tags, sees 456 reserved → cannot create
|
|
1007
|
+
- Developer A pushes branch → content available
|
|
1008
|
+
|
|
1009
|
+
vs. branch-first (problematic):
|
|
1010
|
+
- Developer A pushes branch → visible but not reserved
|
|
1011
|
+
- Developer B checks (no tag yet) → appears available
|
|
1012
|
+
- Developer B creates patch → conflict when pushing tag
|
|
1013
|
+
|
|
1014
|
+
Args:
|
|
1015
|
+
patch_id: Patch identifier (e.g., "456-user-auth")
|
|
1016
|
+
|
|
1017
|
+
Raises:
|
|
1018
|
+
PatchManagerError: If tag push fails
|
|
1019
|
+
|
|
1020
|
+
Examples:
|
|
1021
|
+
self._push_tag_to_reserve_number("456-user-auth")
|
|
1022
|
+
# Pushes tag ho-patch/456 to remote
|
|
1023
|
+
# After this succeeds, patch number is globally reserved
|
|
1024
|
+
"""
|
|
1025
|
+
# Extract patch number
|
|
1026
|
+
patch_number = patch_id.split('-')[0]
|
|
1027
|
+
tag_name = f"ho-patch/{patch_number}"
|
|
1028
|
+
|
|
1029
|
+
try:
|
|
1030
|
+
# Push tag to reserve globally (ATOMIC OPERATION)
|
|
1031
|
+
self._repo.hgit.push_tag(tag_name)
|
|
1032
|
+
except Exception as e:
|
|
1033
|
+
raise PatchManagerError(
|
|
1034
|
+
f"Failed to push reservation tag {tag_name}: {e}\n"
|
|
1035
|
+
f"Patch number reservation failed."
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
def _push_branch_to_remote(self, branch_name: str, retry_count: int = 3) -> None:
|
|
1039
|
+
"""
|
|
1040
|
+
Push branch to remote with automatic retry on failure.
|
|
1041
|
+
|
|
1042
|
+
Attempts to push branch to remote with exponential backoff retry strategy.
|
|
1043
|
+
If tag was already pushed successfully, branch push failure is not critical
|
|
1044
|
+
as the patch number is already reserved. Retries help handle transient
|
|
1045
|
+
network issues.
|
|
1046
|
+
|
|
1047
|
+
Retry strategy:
|
|
1048
|
+
- Attempt 1: immediate
|
|
1049
|
+
- Attempt 2: 1 second delay
|
|
1050
|
+
- Attempt 3: 2 seconds delay
|
|
1051
|
+
- Attempt 4: 4 seconds delay (if retry_count allows)
|
|
1052
|
+
|
|
1053
|
+
Args:
|
|
1054
|
+
branch_name: Full branch name (e.g., "ho-patch/456-user-auth")
|
|
1055
|
+
retry_count: Number of retry attempts (default: 3)
|
|
1056
|
+
|
|
1057
|
+
Raises:
|
|
1058
|
+
PatchManagerError: If all retry attempts fail
|
|
1059
|
+
|
|
1060
|
+
Examples:
|
|
1061
|
+
self._push_branch_to_remote("ho-patch/456-user-auth")
|
|
1062
|
+
# Tries to push branch, retries up to 3 times with backoff
|
|
1063
|
+
|
|
1064
|
+
self._push_branch_to_remote("ho-patch/456-user-auth", retry_count=5)
|
|
1065
|
+
# Custom retry count for unreliable networks
|
|
1066
|
+
"""
|
|
1067
|
+
last_error = None
|
|
1068
|
+
|
|
1069
|
+
for attempt in range(retry_count):
|
|
1070
|
+
try:
|
|
1071
|
+
# Attempt to push branch
|
|
1072
|
+
self._repo.hgit.push_branch(branch_name, set_upstream=True)
|
|
1073
|
+
return # Success!
|
|
1074
|
+
|
|
1075
|
+
except Exception as e:
|
|
1076
|
+
last_error = e
|
|
1077
|
+
|
|
1078
|
+
# If not last attempt, wait before retry
|
|
1079
|
+
if attempt < retry_count - 1:
|
|
1080
|
+
delay = 2 ** attempt # Exponential backoff: 1, 2, 4 seconds
|
|
1081
|
+
time.sleep(delay)
|
|
1082
|
+
|
|
1083
|
+
# All retries failed
|
|
1084
|
+
raise PatchManagerError(
|
|
1085
|
+
f"Failed to push branch {branch_name} after {retry_count} attempts: {last_error}\n"
|
|
1086
|
+
"Check network connection and remote access permissions."
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
def _update_readme_with_description(
|
|
1090
|
+
self,
|
|
1091
|
+
patch_dir: Path,
|
|
1092
|
+
patch_id: str,
|
|
1093
|
+
description: str
|
|
1094
|
+
) -> None:
|
|
1095
|
+
"""
|
|
1096
|
+
Update README.md in patch directory with description.
|
|
1097
|
+
|
|
1098
|
+
Helper method to update the README.md file with user-provided description.
|
|
1099
|
+
Separated from main workflow for clarity and testability.
|
|
1100
|
+
|
|
1101
|
+
Args:
|
|
1102
|
+
patch_dir: Path to patch directory
|
|
1103
|
+
patch_id: Patch identifier for README header
|
|
1104
|
+
description: Description text to add
|
|
1105
|
+
|
|
1106
|
+
Raises:
|
|
1107
|
+
PatchManagerError: If README update fails
|
|
1108
|
+
|
|
1109
|
+
Examples:
|
|
1110
|
+
patch_dir = Path("Patches/456-user-auth")
|
|
1111
|
+
self._update_readme_with_description(
|
|
1112
|
+
patch_dir,
|
|
1113
|
+
"456-user-auth",
|
|
1114
|
+
"Add user authentication system"
|
|
1115
|
+
)
|
|
1116
|
+
# Updates README.md with description
|
|
1117
|
+
"""
|
|
1118
|
+
try:
|
|
1119
|
+
readme_path = patch_dir / "README.md"
|
|
1120
|
+
readme_content = f"# Patch {patch_id}\n\n{description}\n"
|
|
1121
|
+
readme_path.write_text(readme_content, encoding='utf-8')
|
|
1122
|
+
|
|
1123
|
+
except Exception as e:
|
|
1124
|
+
raise PatchManagerError(
|
|
1125
|
+
f"Failed to update README for patch {patch_id}: {e}"
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _rollback_patch_creation(
|
|
1130
|
+
self,
|
|
1131
|
+
initial_branch: str,
|
|
1132
|
+
branch_name: str,
|
|
1133
|
+
patch_id: str,
|
|
1134
|
+
patch_dir: Optional[Path] = None,
|
|
1135
|
+
commit_created: bool = False # DEFAULT: False pour rétrocompatibilité
|
|
1136
|
+
) -> None:
|
|
1137
|
+
"""
|
|
1138
|
+
Rollback patch creation to initial state on failure.
|
|
1139
|
+
|
|
1140
|
+
Performs complete cleanup of all local changes made during patch creation
|
|
1141
|
+
when an error occurs BEFORE the tag is pushed to remote. This ensures a
|
|
1142
|
+
clean repository state for retry.
|
|
1143
|
+
|
|
1144
|
+
UPDATED FOR NEW WORKFLOW: Now handles commit on ho-prod (not on branch).
|
|
1145
|
+
|
|
1146
|
+
Rollback operations (best-effort, continues on individual failures):
|
|
1147
|
+
1. Ensure we're on initial branch (ho-prod)
|
|
1148
|
+
2. Reset commit if it was created (git reset --hard HEAD~1)
|
|
1149
|
+
3. Delete patch branch if it was created (may not exist in new workflow)
|
|
1150
|
+
4. Delete patch tag (local)
|
|
1151
|
+
5. Delete patch directory (if created)
|
|
1152
|
+
|
|
1153
|
+
Note: This method is only called when tag push has NOT succeeded yet.
|
|
1154
|
+
Once tag is pushed, rollback is not performed as the patch number is
|
|
1155
|
+
already globally reserved.
|
|
1156
|
+
|
|
1157
|
+
Args:
|
|
1158
|
+
initial_branch: Branch to return to (usually "ho-prod")
|
|
1159
|
+
branch_name: Patch branch name (e.g., "ho-patch/456-user-auth")
|
|
1160
|
+
patch_id: Patch identifier for tag/directory cleanup
|
|
1161
|
+
patch_dir: Path to patch directory if it was created
|
|
1162
|
+
commit_created: Whether commit was created on ho-prod (NEW)
|
|
1163
|
+
|
|
1164
|
+
Examples:
|
|
1165
|
+
# NEW WORKFLOW: Rollback with commit on ho-prod
|
|
1166
|
+
self._rollback_patch_creation(
|
|
1167
|
+
"ho-prod",
|
|
1168
|
+
"ho-patch/456-user-auth",
|
|
1169
|
+
"456-user-auth",
|
|
1170
|
+
Path("Patches/456-user-auth"),
|
|
1171
|
+
commit_created=True # NEW: commit was made on ho-prod
|
|
1172
|
+
)
|
|
1173
|
+
# Reverts commit, deletes tag/directory, returns to clean state
|
|
1174
|
+
|
|
1175
|
+
# OLD WORKFLOW (still supported): Rollback with commit on branch
|
|
1176
|
+
self._rollback_patch_creation(
|
|
1177
|
+
"ho-prod",
|
|
1178
|
+
"ho-patch/456-user-auth",
|
|
1179
|
+
"456-user-auth",
|
|
1180
|
+
Path("Patches/456-user-auth"),
|
|
1181
|
+
commit_created=False # No commit on ho-prod
|
|
1182
|
+
)
|
|
1183
|
+
"""
|
|
1184
|
+
# Best-effort cleanup - continue even if individual operations fail
|
|
1185
|
+
|
|
1186
|
+
# 1. Ensure we're on initial branch (usually ho-prod)
|
|
1187
|
+
# ALWAYS checkout to ensure we're on the right branch for reset
|
|
1188
|
+
try:
|
|
1189
|
+
self._repo.hgit.checkout(initial_branch)
|
|
1190
|
+
except Exception:
|
|
1191
|
+
# Continue cleanup even if checkout fails
|
|
1192
|
+
pass
|
|
1193
|
+
|
|
1194
|
+
# 2. Reset commit if it was created on ho-prod (NEW WORKFLOW)
|
|
1195
|
+
if commit_created:
|
|
1196
|
+
try:
|
|
1197
|
+
# Hard reset to remove the commit
|
|
1198
|
+
# Using git reset --hard HEAD~1
|
|
1199
|
+
self._repo.hgit._HGit__git_repo.git.reset('--hard', 'HEAD~1')
|
|
1200
|
+
except Exception:
|
|
1201
|
+
# Continue cleanup even if reset fails
|
|
1202
|
+
pass
|
|
1203
|
+
|
|
1204
|
+
# 3. Delete patch branch (may not exist if failure before branch creation)
|
|
1205
|
+
try:
|
|
1206
|
+
self._repo.hgit.delete_local_branch(branch_name)
|
|
1207
|
+
except Exception:
|
|
1208
|
+
# Branch may not exist yet or deletion may fail - continue
|
|
1209
|
+
pass
|
|
1210
|
+
|
|
1211
|
+
# 4. Delete local tag
|
|
1212
|
+
patch_number = patch_id.split('-')[0]
|
|
1213
|
+
tag_name = f"ho-patch/{patch_number}"
|
|
1214
|
+
try:
|
|
1215
|
+
self._repo.hgit.delete_local_tag(tag_name)
|
|
1216
|
+
except Exception:
|
|
1217
|
+
# Tag may not exist yet or deletion may fail - continue
|
|
1218
|
+
pass
|
|
1219
|
+
|
|
1220
|
+
# 5. Delete patch directory (if created)
|
|
1221
|
+
if patch_dir and patch_dir.exists():
|
|
1222
|
+
try:
|
|
1223
|
+
import shutil
|
|
1224
|
+
shutil.rmtree(patch_dir)
|
|
1225
|
+
except Exception:
|
|
1226
|
+
# Directory deletion may fail (permissions, etc.) - continue
|
|
1227
|
+
pass
|
|
1228
|
+
|
|
1229
|
+
def create_patch(self, patch_id: str, description: Optional[str] = None) -> dict:
|
|
1230
|
+
"""
|
|
1231
|
+
Create new patch with atomic tag-first reservation strategy.
|
|
1232
|
+
|
|
1233
|
+
Orchestrates the full patch creation workflow with transactional guarantees:
|
|
1234
|
+
1. Validates we're on ho-prod branch
|
|
1235
|
+
2. Validates repository is clean
|
|
1236
|
+
3. Validates git remote is configured
|
|
1237
|
+
4. Validates and normalizes patch ID format
|
|
1238
|
+
5. Fetches all references from remote (branches + tags)
|
|
1239
|
+
5.5 Validates ho-prod is synced with origin/ho-prod (NEW)
|
|
1240
|
+
6. Checks patch number available via tag lookup
|
|
1241
|
+
7. Creates Patches/PATCH_ID/ directory (on ho-prod)
|
|
1242
|
+
8. Commits directory on ho-prod "Add Patches/{patch_id} directory"
|
|
1243
|
+
9. Creates local tag ho-patch/{number} (points to commit on ho-prod)
|
|
1244
|
+
10. **Pushes tag to reserve number globally** ← POINT OF NO RETURN
|
|
1245
|
+
11. Creates ho-patch/PATCH_ID branch from current commit
|
|
1246
|
+
12. Pushes branch to remote (with retry)
|
|
1247
|
+
13. Checkouts to new patch branch
|
|
1248
|
+
|
|
1249
|
+
Transactional guarantees:
|
|
1250
|
+
- Failure before step 10 (tag push): Complete rollback to initial state
|
|
1251
|
+
- Success at step 10 (tag push): Patch reserved, no rollback even if branch push fails
|
|
1252
|
+
- Tag-first strategy prevents race conditions between developers
|
|
1253
|
+
- Remote fetch + sync validation ensures up-to-date base
|
|
1254
|
+
|
|
1255
|
+
Race condition prevention:
|
|
1256
|
+
Tag pushed BEFORE branch ensures atomic reservation:
|
|
1257
|
+
- Dev A: Push tag → reservation complete
|
|
1258
|
+
- Dev B: Fetch tags → sees reservation → cannot create
|
|
1259
|
+
vs. branch-first approach allows conflicts
|
|
1260
|
+
|
|
1261
|
+
Args:
|
|
1262
|
+
patch_id: Patch identifier (e.g., "456-user-auth")
|
|
1263
|
+
description: Optional description for README and commit message
|
|
1264
|
+
|
|
1265
|
+
Returns:
|
|
1266
|
+
dict: Creation result with keys:
|
|
1267
|
+
- patch_id: Normalized patch identifier
|
|
1268
|
+
- branch_name: Created branch name
|
|
1269
|
+
- patch_dir: Path to patch directory
|
|
1270
|
+
- on_branch: Current branch after checkout
|
|
1271
|
+
|
|
1272
|
+
Raises:
|
|
1273
|
+
PatchManagerError: If validation fails or creation errors occur
|
|
1274
|
+
|
|
1275
|
+
Examples:
|
|
1276
|
+
result = patch_mgr.create_patch("456-user-auth")
|
|
1277
|
+
# Creates patch with all steps, returns on success
|
|
1278
|
+
|
|
1279
|
+
result = patch_mgr.create_patch("456", "Add authentication")
|
|
1280
|
+
# With description for README and commits
|
|
1281
|
+
"""
|
|
1282
|
+
# Step 1-3: Validate context
|
|
1283
|
+
self._validate_on_ho_prod()
|
|
1284
|
+
self._validate_repo_clean()
|
|
1285
|
+
self._validate_has_remote()
|
|
1286
|
+
|
|
1287
|
+
# Step 4: Validate and normalize patch ID
|
|
1288
|
+
try:
|
|
1289
|
+
patch_info = self._validator.validate_patch_id(patch_id)
|
|
1290
|
+
normalized_id = patch_info.normalized_id
|
|
1291
|
+
except Exception as e:
|
|
1292
|
+
raise PatchManagerError(f"Invalid patch ID: {e}")
|
|
1293
|
+
|
|
1294
|
+
# Step 5: Fetch all references from remote (branches + tags)
|
|
1295
|
+
self._fetch_from_remote()
|
|
1296
|
+
|
|
1297
|
+
# Step 5.5: Validate ho-prod is synced with origin (NEW)
|
|
1298
|
+
self._validate_ho_prod_synced_with_origin()
|
|
1299
|
+
|
|
1300
|
+
# Step 6: Check patch number available (via tag)
|
|
1301
|
+
branch_name = f"ho-patch/{normalized_id}"
|
|
1302
|
+
self._check_patch_id_available(normalized_id)
|
|
1303
|
+
|
|
1304
|
+
# Save initial state for rollback
|
|
1305
|
+
initial_branch = self._repo.hgit.branch
|
|
1306
|
+
patch_dir = None
|
|
1307
|
+
commit_created = False
|
|
1308
|
+
tag_pushed = False
|
|
1309
|
+
|
|
1310
|
+
try:
|
|
1311
|
+
# === LOCAL OPERATIONS ON HO-PROD (rollback on failure) ===
|
|
1312
|
+
|
|
1313
|
+
# Step 7: Create patch directory (on ho-prod, not on branch!)
|
|
1314
|
+
patch_dir = self.create_patch_directory(normalized_id)
|
|
1315
|
+
|
|
1316
|
+
# Step 7b: Update README if description provided
|
|
1317
|
+
if description:
|
|
1318
|
+
self._update_readme_with_description(patch_dir, normalized_id, description)
|
|
1319
|
+
|
|
1320
|
+
# Step 8: Commit patch directory ON HO-PROD
|
|
1321
|
+
self._commit_patch_directory(normalized_id, description)
|
|
1322
|
+
commit_created = True # Track that commit was made
|
|
1323
|
+
|
|
1324
|
+
# Step 9: Create local tag (points to commit on ho-prod with Patches/)
|
|
1325
|
+
self._create_local_tag(normalized_id, description)
|
|
1326
|
+
|
|
1327
|
+
# === REMOTE OPERATIONS (point of no return) ===
|
|
1328
|
+
|
|
1329
|
+
# Step 10: Push tag FIRST → ATOMIC RESERVATION
|
|
1330
|
+
self._push_tag_to_reserve_number(normalized_id)
|
|
1331
|
+
self._repo.hgit.push_branch('ho-prod')
|
|
1332
|
+
tag_pushed = True # Tag pushed = point of no return
|
|
1333
|
+
# ✅ If we reach here: patch number globally reserved!
|
|
1334
|
+
|
|
1335
|
+
# === BRANCH CREATION (after reservation) ===
|
|
1336
|
+
|
|
1337
|
+
# Step 11: Create branch FROM current commit (after tag push)
|
|
1338
|
+
self._create_git_branch(branch_name)
|
|
1339
|
+
|
|
1340
|
+
# Step 12: Push branch (with retry)
|
|
1341
|
+
try:
|
|
1342
|
+
self._push_branch_to_remote(branch_name)
|
|
1343
|
+
except PatchManagerError as e:
|
|
1344
|
+
# Tag already pushed = success, just warn about branch
|
|
1345
|
+
import click
|
|
1346
|
+
click.echo(f"⚠️ Warning: Branch push failed after 3 attempts")
|
|
1347
|
+
click.echo(f"⚠️ Patch {normalized_id} is reserved (tag pushed successfully)")
|
|
1348
|
+
click.echo(f"⚠️ Push branch manually: git push -u origin {branch_name}")
|
|
1349
|
+
# Don't raise - tag pushed means success
|
|
1350
|
+
|
|
1351
|
+
except Exception as e:
|
|
1352
|
+
# Only rollback if tag NOT pushed yet
|
|
1353
|
+
if not tag_pushed:
|
|
1354
|
+
self._rollback_patch_creation(
|
|
1355
|
+
initial_branch,
|
|
1356
|
+
branch_name,
|
|
1357
|
+
normalized_id,
|
|
1358
|
+
patch_dir,
|
|
1359
|
+
commit_created=commit_created # Pass commit status
|
|
1360
|
+
)
|
|
1361
|
+
raise PatchManagerError(f"Patch creation failed: {e}")
|
|
1362
|
+
|
|
1363
|
+
# Step 13: Checkout to new branch (non-critical, warn if fails)
|
|
1364
|
+
try:
|
|
1365
|
+
self._checkout_branch(branch_name)
|
|
1366
|
+
except Exception as e:
|
|
1367
|
+
import click
|
|
1368
|
+
click.echo(f"⚠️ Checkout failed but patch created successfully")
|
|
1369
|
+
click.echo(f"Run: git checkout {branch_name}")
|
|
1370
|
+
|
|
1371
|
+
# Return result
|
|
1372
|
+
return {
|
|
1373
|
+
'patch_id': normalized_id,
|
|
1374
|
+
'branch_name': branch_name,
|
|
1375
|
+
'patch_dir': patch_dir,
|
|
1376
|
+
'on_branch': branch_name
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
def _validate_on_ho_prod(self) -> None:
|
|
1380
|
+
"""
|
|
1381
|
+
Validate that current branch is ho-prod.
|
|
1382
|
+
|
|
1383
|
+
The create_patch operation must start from ho-prod branch to ensure
|
|
1384
|
+
patches are based on the current production state.
|
|
1385
|
+
|
|
1386
|
+
Raises:
|
|
1387
|
+
PatchManagerError: If not on ho-prod branch
|
|
1388
|
+
|
|
1389
|
+
Examples:
|
|
1390
|
+
self._validate_on_ho_prod()
|
|
1391
|
+
# Passes if on ho-prod, raises otherwise
|
|
1392
|
+
"""
|
|
1393
|
+
current_branch = self._repo.hgit.branch
|
|
1394
|
+
if current_branch != "ho-prod":
|
|
1395
|
+
raise PatchManagerError(
|
|
1396
|
+
f"Must be on ho-prod branch to create patch. "
|
|
1397
|
+
f"Current branch: {current_branch}"
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
def _validate_repo_clean(self) -> None:
|
|
1401
|
+
"""
|
|
1402
|
+
Validate that git repository has no uncommitted changes.
|
|
1403
|
+
|
|
1404
|
+
Ensures clean state before creating new patch branch to avoid
|
|
1405
|
+
accidentally including unrelated changes in the patch.
|
|
1406
|
+
|
|
1407
|
+
Raises:
|
|
1408
|
+
PatchManagerError: If repository has uncommitted changes
|
|
1409
|
+
|
|
1410
|
+
Examples:
|
|
1411
|
+
self._validate_repo_clean()
|
|
1412
|
+
# Passes if clean, raises if uncommitted changes exist
|
|
1413
|
+
"""
|
|
1414
|
+
if not self._repo.hgit.repos_is_clean():
|
|
1415
|
+
raise PatchManagerError(
|
|
1416
|
+
"Repository has uncommitted changes. "
|
|
1417
|
+
"Commit or stash changes before creating patch."
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
def _create_git_branch(self, branch_name: str) -> None:
|
|
1421
|
+
"""
|
|
1422
|
+
Create new git branch from current HEAD.
|
|
1423
|
+
|
|
1424
|
+
Creates the patch branch in git repository. Branch name follows
|
|
1425
|
+
the convention: ho-patch/PATCH_ID
|
|
1426
|
+
|
|
1427
|
+
Args:
|
|
1428
|
+
branch_name: Full branch name to create (e.g., "ho-patch/456-user-auth")
|
|
1429
|
+
|
|
1430
|
+
Raises:
|
|
1431
|
+
PatchManagerError: If branch creation fails or branch already exists
|
|
1432
|
+
|
|
1433
|
+
Examples:
|
|
1434
|
+
self._create_git_branch("ho-patch/456-user-auth")
|
|
1435
|
+
# Creates branch from current HEAD but doesn't checkout to it
|
|
1436
|
+
"""
|
|
1437
|
+
try:
|
|
1438
|
+
# Use HGit checkout proxy to create branch
|
|
1439
|
+
self._repo.hgit.checkout('-b', branch_name)
|
|
1440
|
+
except GitCommandError as e:
|
|
1441
|
+
if "already exists" in str(e):
|
|
1442
|
+
raise PatchManagerError(
|
|
1443
|
+
f"Branch already exists: {branch_name}"
|
|
1444
|
+
)
|
|
1445
|
+
raise PatchManagerError(
|
|
1446
|
+
f"Failed to create branch {branch_name}: {e}"
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
def _checkout_branch(self, branch_name: str) -> None:
|
|
1450
|
+
"""
|
|
1451
|
+
Checkout to specified branch.
|
|
1452
|
+
|
|
1453
|
+
Switches the working directory to the specified branch.
|
|
1454
|
+
|
|
1455
|
+
Args:
|
|
1456
|
+
branch_name: Branch name to checkout (e.g., "ho-patch/456-user-auth")
|
|
1457
|
+
|
|
1458
|
+
Raises:
|
|
1459
|
+
PatchManagerError: If checkout fails
|
|
1460
|
+
|
|
1461
|
+
Examples:
|
|
1462
|
+
self._checkout_branch("ho-patch/456-user-auth")
|
|
1463
|
+
# Working directory now on ho-patch/456-user-auth
|
|
1464
|
+
"""
|
|
1465
|
+
try:
|
|
1466
|
+
self._repo.hgit.checkout(branch_name)
|
|
1467
|
+
except GitCommandError as e:
|
|
1468
|
+
raise PatchManagerError(
|
|
1469
|
+
f"Failed to checkout branch {branch_name}: {e}"
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
def _validate_has_remote(self) -> None:
|
|
1473
|
+
"""
|
|
1474
|
+
Validate that git remote is configured for patch ID reservation.
|
|
1475
|
+
|
|
1476
|
+
Patch IDs must be globally unique across all developers working
|
|
1477
|
+
on the project. Remote configuration is required to push patch
|
|
1478
|
+
branches and reserve IDs.
|
|
1479
|
+
|
|
1480
|
+
Raises:
|
|
1481
|
+
PatchManagerError: If no git remote configured
|
|
1482
|
+
|
|
1483
|
+
Examples:
|
|
1484
|
+
self._validate_has_remote()
|
|
1485
|
+
# Raises if no origin remote configured
|
|
1486
|
+
"""
|
|
1487
|
+
if not self._repo.hgit.has_remote():
|
|
1488
|
+
raise PatchManagerError(
|
|
1489
|
+
"No git remote configured. Cannot reserve patch ID globally.\n"
|
|
1490
|
+
"Patch IDs must be globally unique across all developers.\n\n"
|
|
1491
|
+
"Configure remote with: git remote add origin <url>"
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
def _push_branch_to_reserve_id(self, branch_name: str) -> None:
|
|
1495
|
+
"""
|
|
1496
|
+
Push branch to remote to reserve patch ID globally.
|
|
1497
|
+
|
|
1498
|
+
Pushes the newly created patch branch to remote, ensuring
|
|
1499
|
+
the patch ID is reserved and preventing conflicts between
|
|
1500
|
+
developers working on different patches.
|
|
1501
|
+
|
|
1502
|
+
Args:
|
|
1503
|
+
branch_name: Branch name to push (e.g., "ho-patch/456-user-auth")
|
|
1504
|
+
|
|
1505
|
+
Raises:
|
|
1506
|
+
PatchManagerError: If push fails
|
|
1507
|
+
|
|
1508
|
+
Examples:
|
|
1509
|
+
self._push_branch_to_reserve_id("ho-patch/456-user-auth")
|
|
1510
|
+
# Branch pushed to origin with upstream tracking
|
|
1511
|
+
"""
|
|
1512
|
+
try:
|
|
1513
|
+
self._repo.hgit.push_branch(branch_name, set_upstream=True)
|
|
1514
|
+
except Exception as e:
|
|
1515
|
+
raise PatchManagerError(
|
|
1516
|
+
f"Failed to push branch {branch_name} to remote: {e}\n"
|
|
1517
|
+
"Patch ID reservation requires successful push to origin.\n"
|
|
1518
|
+
"Check network connection and remote access permissions."
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
def _check_patch_id_available(self, patch_id: str) -> None:
|
|
1522
|
+
"""
|
|
1523
|
+
Check if patch number is available via tag lookup.
|
|
1524
|
+
|
|
1525
|
+
Fetches tags and checks if reservation tag exists.
|
|
1526
|
+
Much more efficient than scanning all branches.
|
|
1527
|
+
|
|
1528
|
+
Args:
|
|
1529
|
+
patch_id: Full patch ID (e.g., "456-user-auth")
|
|
1530
|
+
|
|
1531
|
+
Raises:
|
|
1532
|
+
PatchManagerError: If patch number already reserved
|
|
1533
|
+
|
|
1534
|
+
Examples:
|
|
1535
|
+
self._check_patch_id_available("456-user-auth")
|
|
1536
|
+
# Checks if tag ho-patch/456 exists
|
|
1537
|
+
"""
|
|
1538
|
+
try:
|
|
1539
|
+
# Fetch latest tags from remote
|
|
1540
|
+
self._repo.hgit.fetch_tags()
|
|
1541
|
+
except Exception as e:
|
|
1542
|
+
raise PatchManagerError(
|
|
1543
|
+
f"Failed to fetch tags from remote: {e}\n"
|
|
1544
|
+
f"Cannot verify patch number availability.\n"
|
|
1545
|
+
f"Check network connection and remote access."
|
|
1546
|
+
)
|
|
1547
|
+
|
|
1548
|
+
# Extract patch number
|
|
1549
|
+
patch_number = patch_id.split('-')[0]
|
|
1550
|
+
tag_name = f"ho-patch/{patch_number}"
|
|
1551
|
+
|
|
1552
|
+
# Check if reservation tag exists
|
|
1553
|
+
if self._repo.hgit.tag_exists(tag_name):
|
|
1554
|
+
raise PatchManagerError(
|
|
1555
|
+
f"Patch number {patch_number} already reserved.\n"
|
|
1556
|
+
f"Tag {tag_name} exists on remote.\n"
|
|
1557
|
+
f"Another developer is using this patch number.\n"
|
|
1558
|
+
f"Choose a different patch number."
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _create_reservation_tag(self, patch_id: str, description: Optional[str] = None) -> None:
|
|
1563
|
+
"""
|
|
1564
|
+
Create and push tag to reserve patch number.
|
|
1565
|
+
|
|
1566
|
+
Creates tag ho-patch/{number} to globally reserve the patch number.
|
|
1567
|
+
This prevents other developers from using the same number.
|
|
1568
|
+
|
|
1569
|
+
Args:
|
|
1570
|
+
patch_id: Full patch ID (e.g., "456-user-auth")
|
|
1571
|
+
description: Optional description for tag message
|
|
1572
|
+
|
|
1573
|
+
Raises:
|
|
1574
|
+
PatchManagerError: If tag creation/push fails
|
|
1575
|
+
|
|
1576
|
+
Examples:
|
|
1577
|
+
self._create_reservation_tag("456-user-auth", "Add user authentication")
|
|
1578
|
+
# Creates and pushes tag ho-patch/456
|
|
1579
|
+
"""
|
|
1580
|
+
# Extract patch number
|
|
1581
|
+
patch_number = patch_id.split('-')[0]
|
|
1582
|
+
tag_name = f"ho-patch/{patch_number}"
|
|
1583
|
+
|
|
1584
|
+
# Create tag message
|
|
1585
|
+
if description:
|
|
1586
|
+
tag_message = f"Patch {patch_number}: {description}"
|
|
1587
|
+
else:
|
|
1588
|
+
tag_message = f"Patch {patch_number} reserved"
|
|
1589
|
+
|
|
1590
|
+
try:
|
|
1591
|
+
# Create tag locally
|
|
1592
|
+
self._repo.hgit.create_tag(tag_name, tag_message)
|
|
1593
|
+
|
|
1594
|
+
# Push tag to reserve globally
|
|
1595
|
+
self._repo.hgit.push_tag(tag_name)
|
|
1596
|
+
except Exception as e:
|
|
1597
|
+
raise PatchManagerError(
|
|
1598
|
+
f"Failed to create reservation tag {tag_name}: {e}\n"
|
|
1599
|
+
f"Patch number reservation failed."
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _validate_ho_prod_synced_with_origin(self) -> None:
|
|
1604
|
+
"""
|
|
1605
|
+
Validate that local ho-prod is synchronized with origin/ho-prod.
|
|
1606
|
+
|
|
1607
|
+
Prevents creating patches on an outdated or unsynchronized base which
|
|
1608
|
+
would cause merge conflicts, inconsistent patch history, and potential
|
|
1609
|
+
data loss. Must be called after fetch_from_origin() to ensure accurate
|
|
1610
|
+
comparison.
|
|
1611
|
+
|
|
1612
|
+
Sync requirements:
|
|
1613
|
+
- Local ho-prod must be at the same commit as origin/ho-prod (synced)
|
|
1614
|
+
- If ahead: Must push local commits before creating patch
|
|
1615
|
+
- If behind: Must pull remote commits before creating patch
|
|
1616
|
+
- If diverged: Must resolve conflicts before creating patch
|
|
1617
|
+
|
|
1618
|
+
Raises:
|
|
1619
|
+
PatchManagerError: If ho-prod is not synced with origin with specific
|
|
1620
|
+
guidance on how to resolve the sync issue
|
|
1621
|
+
|
|
1622
|
+
Examples:
|
|
1623
|
+
# Successful validation (synced)
|
|
1624
|
+
self._fetch_from_remote()
|
|
1625
|
+
self._validate_ho_prod_synced_with_origin()
|
|
1626
|
+
# Continues to patch creation
|
|
1627
|
+
|
|
1628
|
+
# Failed validation (behind)
|
|
1629
|
+
try:
|
|
1630
|
+
self._validate_ho_prod_synced_with_origin()
|
|
1631
|
+
except PatchManagerError as e:
|
|
1632
|
+
# Error: "ho-prod is behind origin/ho-prod. Run: git pull"
|
|
1633
|
+
|
|
1634
|
+
# Failed validation (ahead)
|
|
1635
|
+
try:
|
|
1636
|
+
self._validate_ho_prod_synced_with_origin()
|
|
1637
|
+
except PatchManagerError as e:
|
|
1638
|
+
# Error: "ho-prod is ahead of origin/ho-prod. Run: git push"
|
|
1639
|
+
|
|
1640
|
+
# Failed validation (diverged)
|
|
1641
|
+
try:
|
|
1642
|
+
self._validate_ho_prod_synced_with_origin()
|
|
1643
|
+
except PatchManagerError as e:
|
|
1644
|
+
# Error: "ho-prod has diverged from origin/ho-prod.
|
|
1645
|
+
# Resolve conflicts first."
|
|
1646
|
+
"""
|
|
1647
|
+
try:
|
|
1648
|
+
# Check sync status with origin
|
|
1649
|
+
is_synced, status = self._repo.hgit.is_branch_synced("ho-prod", remote="origin")
|
|
1650
|
+
|
|
1651
|
+
if is_synced:
|
|
1652
|
+
# All good - ho-prod is synced with origin
|
|
1653
|
+
return
|
|
1654
|
+
|
|
1655
|
+
# Not synced - provide specific guidance based on status
|
|
1656
|
+
if status == "ahead":
|
|
1657
|
+
raise PatchManagerError(
|
|
1658
|
+
"ho-prod is ahead of origin/ho-prod.\n"
|
|
1659
|
+
"Push your local commits before creating patch:\n"
|
|
1660
|
+
" git push origin ho-prod"
|
|
1661
|
+
)
|
|
1662
|
+
elif status == "behind":
|
|
1663
|
+
raise PatchManagerError(
|
|
1664
|
+
"ho-prod is behind origin/ho-prod.\n"
|
|
1665
|
+
"Pull remote commits before creating patch:\n"
|
|
1666
|
+
" git pull origin ho-prod"
|
|
1667
|
+
)
|
|
1668
|
+
elif status == "diverged":
|
|
1669
|
+
raise PatchManagerError(
|
|
1670
|
+
"ho-prod has diverged from origin/ho-prod.\n"
|
|
1671
|
+
"Resolve conflicts before creating patch:\n"
|
|
1672
|
+
" git pull --rebase origin ho-prod\n"
|
|
1673
|
+
" or\n"
|
|
1674
|
+
" git pull origin ho-prod (and resolve merge conflicts)"
|
|
1675
|
+
)
|
|
1676
|
+
else:
|
|
1677
|
+
# Unknown status - generic error
|
|
1678
|
+
raise PatchManagerError(
|
|
1679
|
+
f"ho-prod sync check failed with status: {status}\n"
|
|
1680
|
+
"Ensure ho-prod is synchronized with origin before creating patch."
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
except GitCommandError as e:
|
|
1684
|
+
raise PatchManagerError(
|
|
1685
|
+
f"Failed to check ho-prod sync status: {e}\n"
|
|
1686
|
+
"Ensure origin remote is configured and accessible."
|
|
1687
|
+
)
|
|
1688
|
+
except PatchManagerError:
|
|
1689
|
+
# Re-raise PatchManagerError as-is
|
|
1690
|
+
raise
|
|
1691
|
+
except Exception as e:
|
|
1692
|
+
raise PatchManagerError(
|
|
1693
|
+
f"Unexpected error checking ho-prod sync: {e}"
|
|
1694
|
+
)
|