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.
Files changed (58) hide show
  1. half_orm_dev/__init__.py +1 -0
  2. half_orm_dev/cli/__init__.py +9 -0
  3. half_orm_dev/cli/commands/__init__.py +56 -0
  4. half_orm_dev/cli/commands/apply.py +13 -0
  5. half_orm_dev/cli/commands/clone.py +102 -0
  6. half_orm_dev/cli/commands/init.py +331 -0
  7. half_orm_dev/cli/commands/new.py +15 -0
  8. half_orm_dev/cli/commands/patch.py +317 -0
  9. half_orm_dev/cli/commands/prepare.py +21 -0
  10. half_orm_dev/cli/commands/prepare_release.py +119 -0
  11. half_orm_dev/cli/commands/promote_to.py +127 -0
  12. half_orm_dev/cli/commands/release.py +344 -0
  13. half_orm_dev/cli/commands/restore.py +14 -0
  14. half_orm_dev/cli/commands/sync.py +13 -0
  15. half_orm_dev/cli/commands/todo.py +73 -0
  16. half_orm_dev/cli/commands/undo.py +17 -0
  17. half_orm_dev/cli/commands/update.py +73 -0
  18. half_orm_dev/cli/commands/upgrade.py +191 -0
  19. half_orm_dev/cli/main.py +103 -0
  20. half_orm_dev/cli_extension.py +38 -0
  21. half_orm_dev/database.py +1389 -0
  22. half_orm_dev/hgit.py +1025 -0
  23. half_orm_dev/hop.py +167 -0
  24. half_orm_dev/manifest.py +43 -0
  25. half_orm_dev/modules.py +456 -0
  26. half_orm_dev/patch.py +281 -0
  27. half_orm_dev/patch_manager.py +1694 -0
  28. half_orm_dev/patch_validator.py +335 -0
  29. half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +34 -0
  30. half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +2 -0
  31. half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +3 -0
  32. half_orm_dev/patches/log +2 -0
  33. half_orm_dev/patches/sql/half_orm_meta.sql +208 -0
  34. half_orm_dev/release_manager.py +2841 -0
  35. half_orm_dev/repo.py +1562 -0
  36. half_orm_dev/templates/.gitignore +15 -0
  37. half_orm_dev/templates/MANIFEST.in +1 -0
  38. half_orm_dev/templates/Pipfile +13 -0
  39. half_orm_dev/templates/README +25 -0
  40. half_orm_dev/templates/conftest_template +42 -0
  41. half_orm_dev/templates/init_module_template +10 -0
  42. half_orm_dev/templates/module_template_1 +12 -0
  43. half_orm_dev/templates/module_template_2 +6 -0
  44. half_orm_dev/templates/module_template_3 +3 -0
  45. half_orm_dev/templates/relation_test +23 -0
  46. half_orm_dev/templates/setup.py +81 -0
  47. half_orm_dev/templates/sql_adapter +9 -0
  48. half_orm_dev/templates/warning +12 -0
  49. half_orm_dev/utils.py +49 -0
  50. half_orm_dev/version.txt +1 -0
  51. half_orm_dev-0.16.0a9.dist-info/METADATA +935 -0
  52. half_orm_dev-0.16.0a9.dist-info/RECORD +58 -0
  53. half_orm_dev-0.16.0a9.dist-info/WHEEL +5 -0
  54. half_orm_dev-0.16.0a9.dist-info/licenses/AUTHORS +3 -0
  55. half_orm_dev-0.16.0a9.dist-info/licenses/LICENSE +14 -0
  56. half_orm_dev-0.16.0a9.dist-info/top_level.txt +2 -0
  57. tests/__init__.py +0 -0
  58. 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
+ )