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,2841 @@
1
+ """
2
+ ReleaseManager module for half-orm-dev
3
+
4
+ Manages release files (releases/*.txt), version calculation, and release
5
+ lifecycle (stage → rc → production) for the Git-centric workflow.
6
+ """
7
+
8
+ import fnmatch
9
+ import os
10
+ import re
11
+ import sys
12
+ import subprocess
13
+
14
+ from pathlib import Path
15
+ from typing import Optional, Tuple, List, Dict
16
+ from dataclasses import dataclass
17
+
18
+ from git.exc import GitCommandError
19
+
20
+ class ReleaseManagerError(Exception):
21
+ """Base exception for ReleaseManager operations."""
22
+ pass
23
+
24
+
25
+ class ReleaseVersionError(ReleaseManagerError):
26
+ """Raised when version calculation or parsing fails."""
27
+ pass
28
+
29
+
30
+ class ReleaseFileError(ReleaseManagerError):
31
+ """Raised when release file operations fail."""
32
+ pass
33
+
34
+
35
+ @dataclass
36
+ class Version:
37
+ """Semantic version with stage information."""
38
+ major: int
39
+ minor: int
40
+ patch: int
41
+ stage: Optional[str] = None # None, "stage", "rc1", "rc2", "hotfix1", etc.
42
+
43
+ def __str__(self) -> str:
44
+ """String representation of version."""
45
+ base = f"{self.major}.{self.minor}.{self.patch}"
46
+ if self.stage:
47
+ return f"{base}-{self.stage}"
48
+ return base
49
+
50
+ def __lt__(self, other: 'Version') -> bool:
51
+ """Compare versions for sorting."""
52
+ # Compare base version first
53
+ if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
54
+ return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
55
+
56
+ # If base versions equal, compare stages
57
+ # Priority: production (None) > rc > stage > hotfix
58
+ stage_priority = {
59
+ None: 4, # Production (highest)
60
+ 'rc': 3, # Release candidate
61
+ 'stage': 2, # Development stage
62
+ 'hotfix': 1 # Hotfix (lowest)
63
+ }
64
+
65
+ # Extract stage type (rc1 → rc, hotfix2 → hotfix)
66
+ self_stage_type = self._get_stage_type()
67
+ other_stage_type = other._get_stage_type()
68
+
69
+ self_priority = stage_priority.get(self_stage_type, 0)
70
+ other_priority = stage_priority.get(other_stage_type, 0)
71
+
72
+ # If different stage types, compare by priority
73
+ if self_priority != other_priority:
74
+ return self_priority < other_priority
75
+
76
+ # Same stage type - compare stage strings for RC/hotfix numbers
77
+ # rc2 > rc1, hotfix2 > hotfix1
78
+ if self.stage and other.stage:
79
+ return self.stage < other.stage
80
+
81
+ return False
82
+
83
+ def _get_stage_type(self) -> Optional[str]:
84
+ """Extract stage type from stage string."""
85
+ if not self.stage:
86
+ return None
87
+
88
+ if self.stage == 'stage':
89
+ return 'stage'
90
+ elif self.stage.startswith('rc'):
91
+ return 'rc'
92
+ elif self.stage.startswith('hotfix'):
93
+ return 'hotfix'
94
+
95
+ return None
96
+
97
+
98
+ class ReleaseManager:
99
+ """
100
+ Manages release files and version lifecycle.
101
+
102
+ Handles creation, validation, and management of releases/*.txt files
103
+ following the Git-centric workflow specifications.
104
+
105
+ Release stages:
106
+ - X.Y.Z-stage.txt: Development stage (mutable)
107
+ - X.Y.Z-rc[N].txt: Release candidate (immutable)
108
+ - X.Y.Z.txt: Production release (immutable)
109
+ - X.Y.Z-hotfix[N].txt: Emergency hotfix (immutable)
110
+
111
+ Examples:
112
+ # Prepare new release
113
+ release_mgr = ReleaseManager(repo)
114
+ result = release_mgr.prepare_release('minor')
115
+ # Creates releases/1.4.0-stage.txt
116
+
117
+ # Find latest version
118
+ version = release_mgr.find_latest_version()
119
+ print(f"Latest: {version}") # "1.3.5-rc2"
120
+
121
+ # Calculate next version
122
+ next_ver = release_mgr.calculate_next_version(version, 'patch')
123
+ print(f"Next: {next_ver}") # "1.3.6"
124
+ """
125
+
126
+ def __init__(self, repo):
127
+ """
128
+ Initialize ReleaseManager.
129
+
130
+ Args:
131
+ repo: Repo instance providing access to repository state
132
+ """
133
+ self._repo = repo
134
+ self._base_dir = str(repo.base_dir)
135
+ self._releases_dir = Path(repo.base_dir) / "releases"
136
+
137
+ def prepare_release(self, increment_type: str) -> dict:
138
+ """
139
+ Prepare next release stage file.
140
+
141
+ Creates new releases/X.Y.Z-stage.txt file based on latest version
142
+ and increment type. Validates repository state, synchronizes with
143
+ origin, and pushes to reserve version globally.
144
+
145
+ Workflow:
146
+ 1. Validate on ho-prod branch
147
+ 2. Validate repository is clean
148
+ 3. Fetch from origin
149
+ 4. Synchronize with origin/ho-prod (pull if behind)
150
+ 5. Read production version from model/schema.sql
151
+ 6. Calculate next version based on increment type
152
+ 7. Verify stage file doesn't already exist
153
+ 8. Create empty stage file
154
+ 9. Commit with message "Prepare release X.Y.Z-stage"
155
+ 10. Push to origin (global reservation)
156
+
157
+ Branch requirements:
158
+ - Must be on ho-prod branch
159
+ - Repository must be clean (no uncommitted changes)
160
+ - Must be synced with origin/ho-prod (auto-pull if behind)
161
+
162
+ Synchronization behavior:
163
+ - "synced": Continue
164
+ - "behind": Auto-pull with message
165
+ - "ahead": Continue (will push at end)
166
+ - "diverged": Error - manual merge required
167
+
168
+ Args:
169
+ increment_type: Version increment ("major", "minor", or "patch")
170
+
171
+ Returns:
172
+ dict: Preparation result with keys:
173
+ - version: New version string (e.g., "1.4.0")
174
+ - file: Path to created stage file
175
+ - previous_version: Previous production version
176
+
177
+ Raises:
178
+ ReleaseManagerError: If validation fails
179
+ ReleaseManagerError: If not on ho-prod branch
180
+ ReleaseManagerError: If repository not clean
181
+ ReleaseManagerError: If ho-prod diverged from origin
182
+ ReleaseFileError: If stage file already exists
183
+ ReleaseVersionError: If version calculation fails
184
+
185
+ Examples:
186
+ # Prepare minor release
187
+ result = release_mgr.prepare_release('minor')
188
+ # Production was 1.3.5 → creates releases/1.4.0-stage.txt
189
+
190
+ # Prepare patch release
191
+ result = release_mgr.prepare_release('patch')
192
+ # Production was 1.3.5 → creates releases/1.3.6-stage.txt
193
+
194
+ # Error handling
195
+ try:
196
+ result = release_mgr.prepare_release('major')
197
+ except ReleaseManagerError as e:
198
+ print(f"Failed: {e}")
199
+ """
200
+ # 1. Validate on ho-prod branch
201
+ if self._repo.hgit.branch != 'ho-prod':
202
+ raise ReleaseManagerError(
203
+ f"Must be on ho-prod branch to prepare release.\n"
204
+ f"Current branch: {self._repo.hgit.branch}\n"
205
+ f"Switch to ho-prod: git checkout ho-prod"
206
+ )
207
+
208
+ # 2. Validate repository is clean
209
+ if not self._repo.hgit.repos_is_clean():
210
+ raise ReleaseManagerError(
211
+ "Repository has uncommitted changes.\n"
212
+ "Commit or stash changes before preparing release:\n"
213
+ " git status\n"
214
+ " git add . && git commit"
215
+ )
216
+
217
+ # 3. Fetch from origin
218
+ self._repo.hgit.fetch_from_origin()
219
+
220
+ # 4. Synchronize with origin
221
+ is_synced, status = self._repo.hgit.is_branch_synced("ho-prod")
222
+
223
+ if status == "behind":
224
+ # Pull automatically
225
+ self._repo.hgit.pull()
226
+ elif status == "diverged":
227
+ raise ReleaseManagerError(
228
+ "ho-prod has diverged from origin/ho-prod.\n"
229
+ "Manual resolution required:\n"
230
+ " git pull --rebase origin ho-prod\n"
231
+ " or\n"
232
+ " git merge origin/ho-prod"
233
+ )
234
+ # If "synced" or "ahead", continue
235
+
236
+ # 5. Read production version from model/schema.sql
237
+ prod_version_str = self._get_production_version()
238
+
239
+ # Parse into Version object for calculation
240
+ prod_version = self.parse_version_from_filename(f"{prod_version_str}.txt")
241
+
242
+ # 6. Calculate next version
243
+ next_version = self.calculate_next_version(prod_version, increment_type)
244
+
245
+ # 7. Verify stage file doesn't exist
246
+ stage_file = self._releases_dir / f"{next_version}-stage.txt"
247
+ if stage_file.exists():
248
+ raise ReleaseFileError(
249
+ f"Stage file already exists: {stage_file}\n"
250
+ f"Version {next_version} is already in development.\n"
251
+ f"To continue with this version, use existing stage file."
252
+ )
253
+
254
+ # 8. Create empty stage file
255
+ stage_file.touch()
256
+
257
+ # 9. Commit
258
+ self._repo.hgit.add(str(stage_file))
259
+ self._repo.hgit.commit("-m", f"Prepare release {next_version}-stage")
260
+
261
+ # 10. Push to origin (global reservation)
262
+ self._repo.hgit.push()
263
+
264
+ # Return result
265
+ return {
266
+ 'version': next_version,
267
+ 'file': str(stage_file),
268
+ 'previous_version': prod_version_str
269
+ }
270
+
271
+ def _get_production_version(self) -> str:
272
+ """
273
+ Get production version from model/schema.sql symlink.
274
+
275
+ Reads the version from model/schema.sql symlink target filename.
276
+ Validates consistency with database metadata if accessible.
277
+
278
+ Returns:
279
+ str: Production version (e.g., "1.3.5")
280
+
281
+ Raises:
282
+ ReleaseFileError: If model/ directory or schema.sql missing
283
+ ReleaseFileError: If symlink target has invalid format
284
+
285
+ Examples:
286
+ # schema.sql -> schema-1.3.5.sql
287
+ version = mgr._get_production_version()
288
+ # Returns: "1.3.5"
289
+ """
290
+ schema_path = Path(self._base_dir) / "model" / "schema.sql"
291
+
292
+ # Parse version from symlink
293
+ version_from_file = self._parse_version_from_symlink(schema_path)
294
+
295
+ # Optional validation against database
296
+ try:
297
+ version_from_db = self._repo.database.last_release_s
298
+ if version_from_file != version_from_db:
299
+ self._repo.restore_database_from_schema()
300
+ except Exception:
301
+ # Database not accessible or no metadata: OK, continue
302
+ pass
303
+
304
+ return version_from_file
305
+
306
+ def _parse_version_from_symlink(self, schema_path: Path) -> str:
307
+ """
308
+ Parse version from model/schema.sql symlink target.
309
+
310
+ Extracts version number from symlink target filename following
311
+ the pattern schema-X.Y.Z.sql.
312
+
313
+ Args:
314
+ schema_path: Path to model/schema.sql symlink
315
+
316
+ Returns:
317
+ str: Version string (e.g., "1.3.5")
318
+
319
+ Raises:
320
+ ReleaseFileError: If symlink missing, broken, or invalid format
321
+
322
+ Examples:
323
+ # schema.sql -> schema-1.3.5.sql
324
+ version = mgr._parse_version_from_symlink(Path("model/schema.sql"))
325
+ # Returns: "1.3.5"
326
+ """
327
+ import re
328
+
329
+ # Check model/ directory exists
330
+ model_dir = schema_path.parent
331
+ if not model_dir.exists():
332
+ raise ReleaseFileError(
333
+ f"Model directory not found: {model_dir}\n"
334
+ "Run 'half_orm dev init-project' first."
335
+ )
336
+
337
+ # Check schema.sql exists
338
+ if not schema_path.exists():
339
+ raise ReleaseFileError(
340
+ f"Production schema file not found: {schema_path}\n"
341
+ "Run 'half_orm dev init-project' to generate initial schema."
342
+ )
343
+
344
+ # Check it's a symlink
345
+ if not schema_path.is_symlink():
346
+ raise ReleaseFileError(
347
+ f"Expected symlink but found regular file: {schema_path}"
348
+ )
349
+
350
+ # Get symlink target
351
+ target = Path(os.readlink(schema_path))
352
+ target_name = target.name if hasattr(target, 'name') else str(target)
353
+
354
+ # Parse version from target filename: schema-X.Y.Z.sql
355
+ pattern = r'^schema-(\d+\.\d+\.\d+)\.sql$'
356
+ match = re.match(pattern, target_name)
357
+
358
+ if not match:
359
+ raise ReleaseFileError(
360
+ f"Invalid schema symlink target format: {target_name}\n"
361
+ f"Expected: schema-X.Y.Z.sql (e.g., schema-1.3.5.sql)"
362
+ )
363
+
364
+ # Extract version from capture group
365
+ version = match.group(1)
366
+
367
+ return version
368
+
369
+ def find_latest_version(self) -> Optional[Version]:
370
+ """
371
+ Find latest version across all release stages.
372
+
373
+ Scans releases/ directory for all .txt files and identifies the
374
+ highest version considering stage priority:
375
+ - Production releases (X.Y.Z.txt) have highest priority
376
+ - RC releases (X.Y.Z-rc[N].txt) have second priority
377
+ - Stage releases (X.Y.Z-stage.txt) have third priority
378
+ - Hotfix releases (X.Y.Z-hotfix[N].txt) have fourth priority
379
+
380
+ Returns None if no release files exist (first release).
381
+
382
+ Version comparison:
383
+ - Base version compared first (1.4.0 > 1.3.9)
384
+ - Stage priority used for same base (1.3.5.txt > 1.3.5-rc2.txt)
385
+ - RC number compared within RC stage (1.3.5-rc2 > 1.3.5-rc1)
386
+
387
+ Returns:
388
+ Optional[Version]: Latest version or None if no releases exist
389
+
390
+ Raises:
391
+ ReleaseVersionError: If version parsing fails
392
+ ReleaseFileError: If releases/ directory not found
393
+
394
+ Examples:
395
+ # With releases/1.3.4.txt, releases/1.3.5-stage.txt
396
+ version = release_mgr.find_latest_version()
397
+ print(version) # "1.3.5-stage"
398
+
399
+ # With releases/1.3.4.txt, releases/1.3.5-rc2.txt
400
+ version = release_mgr.find_latest_version()
401
+ print(version) # "1.3.5-rc2"
402
+
403
+ # No release files
404
+ version = release_mgr.find_latest_version()
405
+ print(version) # None
406
+ """
407
+ # Check releases/ directory exists
408
+ if not self._releases_dir.exists():
409
+ raise ReleaseFileError(
410
+ f"Releases directory not found: {self._releases_dir}"
411
+ )
412
+
413
+ # Get all .txt files in releases/
414
+ release_files = list(self._releases_dir.glob("*.txt"))
415
+
416
+ if not release_files:
417
+ return None
418
+
419
+ # Parse all valid versions
420
+ versions = []
421
+ for release_file in release_files:
422
+ try:
423
+ version = self.parse_version_from_filename(release_file.name)
424
+ versions.append(version)
425
+ except ReleaseVersionError:
426
+ # Ignore files with invalid format
427
+ continue
428
+
429
+ if not versions:
430
+ return None
431
+
432
+ # Sort versions and return latest
433
+ # Version.__lt__ handles sorting with stage priority
434
+ return max(versions)
435
+
436
+
437
+ def calculate_next_version(
438
+ self,
439
+ current_version: Optional[Version],
440
+ increment_type: str
441
+ ) -> str:
442
+ """
443
+ Calculate next version based on increment type.
444
+
445
+ Computes the next semantic version from current version and
446
+ increment type. Handles first release (0.0.1) when no current
447
+ version exists.
448
+
449
+ Increment rules:
450
+ - "major": Increment major, reset minor and patch to 0
451
+ - "minor": Keep major, increment minor, reset patch to 0
452
+ - "patch": Keep major and minor, increment patch
453
+
454
+ Examples with current version 1.3.5:
455
+ - major → 2.0.0
456
+ - minor → 1.4.0
457
+ - patch → 1.3.6
458
+
459
+ First release (current_version is None):
460
+ - Any increment type → 0.0.1
461
+
462
+ Args:
463
+ current_version: Current version or None for first release
464
+ increment_type: "major", "minor", or "patch"
465
+
466
+ Returns:
467
+ str: Next version string (e.g., "1.4.0", "2.0.0")
468
+
469
+ Raises:
470
+ ReleaseVersionError: If increment_type invalid
471
+
472
+ Examples:
473
+ # From 1.3.5 to major
474
+ version = Version(1, 3, 5)
475
+ next_ver = release_mgr.calculate_next_version(version, 'major')
476
+ print(next_ver) # "2.0.0"
477
+
478
+ # From 1.3.5 to minor
479
+ next_ver = release_mgr.calculate_next_version(version, 'minor')
480
+ print(next_ver) # "1.4.0"
481
+
482
+ # From 1.3.5 to patch
483
+ next_ver = release_mgr.calculate_next_version(version, 'patch')
484
+ print(next_ver) # "1.3.6"
485
+
486
+ # First release
487
+ next_ver = release_mgr.calculate_next_version(None, 'minor')
488
+ print(next_ver) # "0.0.1"
489
+ """
490
+ # Validate increment type
491
+ valid_types = ['major', 'minor', 'patch']
492
+ if not increment_type or increment_type not in valid_types:
493
+ raise ReleaseVersionError(
494
+ f"Invalid increment type: '{increment_type}'. "
495
+ f"Must be one of: {', '.join(valid_types)}"
496
+ )
497
+
498
+ # Calculate next version based on increment type
499
+ if increment_type == 'major':
500
+ return f"{current_version.major + 1}.0.0"
501
+ elif increment_type == 'minor':
502
+ return f"{current_version.major}.{current_version.minor + 1}.0"
503
+ elif increment_type == 'patch':
504
+ return f"{current_version.major}.{current_version.minor}.{current_version.patch + 1}"
505
+
506
+ # Should never reach here due to validation above
507
+ raise ReleaseVersionError(f"Unexpected increment type: {increment_type}")
508
+
509
+ @classmethod
510
+ def parse_version_from_filename(cls, filename: str) -> Version:
511
+ """
512
+ Parse version from release filename.
513
+
514
+ Extracts semantic version and stage from release filename.
515
+
516
+ Supported formats:
517
+ - X.Y.Z.txt → Version(X, Y, Z, stage=None)
518
+ - X.Y.Z-stage.txt → Version(X, Y, Z, stage="stage")
519
+ - X.Y.Z-rc1.txt → Version(X, Y, Z, stage="rc1")
520
+ - X.Y.Z-hotfix1.txt → Version(X, Y, Z, stage="hotfix1")
521
+
522
+ Args:
523
+ filename: Release filename (e.g., "1.3.5-rc2.txt")
524
+
525
+ Returns:
526
+ Version: Parsed version object
527
+
528
+ Raises:
529
+ ReleaseVersionError: If filename format invalid
530
+
531
+ Examples:
532
+ ver = release_mgr.parse_version_from_filename("1.3.5.txt")
533
+ # Version(1, 3, 5, stage=None)
534
+
535
+ ver = release_mgr.parse_version_from_filename("1.4.0-stage.txt")
536
+ # Version(1, 4, 0, stage="stage")
537
+
538
+ ver = release_mgr.parse_version_from_filename("1.3.5-rc2.txt")
539
+ # Version(1, 3, 5, stage="rc2")
540
+ """
541
+ import re
542
+ from pathlib import Path
543
+
544
+ # Extract just filename if path provided
545
+ filename = Path(filename).name
546
+
547
+ # Validate not empty
548
+ if not filename:
549
+ raise ReleaseVersionError("Invalid format: empty filename")
550
+
551
+ # Must end with .txt
552
+ if not filename.endswith('.txt'):
553
+ raise ReleaseVersionError(f"Invalid format: missing .txt extension in '{filename}'")
554
+
555
+ # Remove .txt extension
556
+ version_str = filename[:-4]
557
+
558
+ # Pattern: X.Y.Z or X.Y.Z-stage or X.Y.Z-rc1 or X.Y.Z-hotfix1
559
+ pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-(stage|rc\d+|hotfix\d+))?$'
560
+
561
+ match = re.match(pattern, version_str)
562
+
563
+ if not match:
564
+ raise ReleaseVersionError(
565
+ f"Invalid format: '{filename}' does not match X.Y.Z[-stage].txt pattern"
566
+ )
567
+
568
+ major, minor, patch, stage = match.groups()
569
+
570
+ # Convert to integers
571
+ try:
572
+ major = int(major)
573
+ minor = int(minor)
574
+ patch = int(patch)
575
+ except ValueError:
576
+ raise ReleaseVersionError(f"Invalid format: non-numeric version components in '{filename}'")
577
+
578
+ # Validate non-negative
579
+ if major < 0 or minor < 0 or patch < 0:
580
+ raise ReleaseVersionError(f"Invalid format: negative version numbers in '{filename}'")
581
+
582
+ return Version(major, minor, patch, stage)
583
+
584
+ def get_next_release_version(self) -> Optional[str]:
585
+ """
586
+ Détermine LA prochaine release à déployer.
587
+
588
+ Returns:
589
+ Version string ou None
590
+ """
591
+ production_str = self._get_production_version()
592
+
593
+ for level in ['patch', 'minor', 'major']:
594
+ next_version = self.calculate_next_version(
595
+ self.parse_version_from_filename(f"{production_str}.txt"), level)
596
+
597
+ # Cherche RC ou stage pour cette version
598
+ rc_pattern = f"{next_version}-rc*.txt"
599
+ stage_file = self._releases_dir / f"{next_version}-stage.txt"
600
+
601
+ if list(self._releases_dir.glob(rc_pattern)) or stage_file.exists():
602
+ return next_version
603
+
604
+ return None
605
+
606
+ def get_rc_files(self, version: str) -> List[str]:
607
+ """
608
+ Liste tous les fichiers RC pour une version, triés par numéro.
609
+
610
+ Returns:
611
+ Liste triée (ex: ["1.3.6-rc1.txt", "1.3.6-rc2.txt"])
612
+ """
613
+ pattern = f"{version}-rc*.txt"
614
+ rc_pattern = re.compile(r'-rc(\d+)\.txt$')
615
+ rc_files = list(self._releases_dir.glob(pattern))
616
+
617
+ return sorted(rc_files, key=lambda f: int(re.search(rc_pattern, f.name).group(1)))
618
+
619
+ def read_release_patches(self, filename: str) -> List[str]:
620
+ """
621
+ Lit les patch IDs d'un fichier de release.
622
+
623
+ Ignore:
624
+ - Lignes vides
625
+ - Commentaires (#)
626
+ - Whitespace
627
+ """
628
+ file_path = self._releases_dir / filename
629
+
630
+ if not file_path.exists():
631
+ return []
632
+
633
+ patch_ids = []
634
+ with open(file_path, 'r', encoding='utf-8') as f:
635
+ for line in f:
636
+ line = line.strip()
637
+ if line and not line.startswith('#'):
638
+ patch_ids.append(line)
639
+
640
+ return patch_ids
641
+
642
+ def get_all_release_context_patches(self) -> List[str]:
643
+ """
644
+ Récupère TOUS les patches du contexte de la prochaine release.
645
+
646
+ IMPORTANT: Application séquentielle des RC incrémentaux.
647
+ - rc1: patches initiaux (ex: 123, 456, 789)
648
+ - rc2: patches nouveaux (ex: 999)
649
+ - rc3: patches nouveaux (ex: 888, 777)
650
+
651
+ Résultat: [123, 456, 789, 999, 888, 777]
652
+
653
+ Pas de déduplication car chaque RC est incrémental.
654
+
655
+ Returns:
656
+ Liste ordonnée des patch IDs (séquence complète)
657
+
658
+ Examples:
659
+ # Production: 1.3.5
660
+ # 1.3.6-rc1.txt: 123, 456, 789
661
+ # 1.3.6-rc2.txt: 999
662
+ # 1.3.6-stage.txt: 234, 567
663
+
664
+ patches = mgr.get_all_release_context_patches()
665
+ # → ["123", "456", "789", "999", "234", "567"]
666
+
667
+ # Pour apply-patch sur patch 888:
668
+ # 1. Restore DB (1.3.5)
669
+ # 2. Apply 123, 456, 789 (rc1)
670
+ # 3. Apply 999 (rc2)
671
+ # 4. Apply 234, 567 (stage)
672
+ # 5. Apply 888 (patch courant)
673
+ """
674
+ next_version = self.get_next_release_version()
675
+
676
+ if not next_version:
677
+ return []
678
+
679
+ all_patches = []
680
+
681
+ # 1. Appliquer tous les RC dans l'ordre (incrémentaux)
682
+ rc_files = self.get_rc_files(next_version)
683
+ for rc_file in rc_files:
684
+ patches = self.read_release_patches(rc_file)
685
+ # Chaque RC est incrémental, pas besoin de déduplication
686
+ all_patches.extend(patches)
687
+
688
+ # 2. Appliquer stage (nouveaux patches en développement)
689
+ stage_file = f"{next_version}-stage.txt"
690
+ stage_patches = self.read_release_patches(stage_file)
691
+ all_patches.extend(stage_patches)
692
+
693
+ return all_patches
694
+
695
+ def add_patch_to_release(self, patch_id: str, to_version: Optional[str] = None) -> dict:
696
+ """
697
+ Add patch to stage release file with validation and exclusive lock.
698
+
699
+ Complete workflow with distributed lock to prevent race conditions:
700
+ 1. Pre-lock validations (branch, clean, patch exists)
701
+ 2. Detect target stage file (auto or explicit)
702
+ 3. Check patch not already in release
703
+ 4. Acquire exclusive lock on ho-prod (atomic via Git tag)
704
+ 5. Sync with origin (fetch + pull if needed)
705
+ 6. Create temporary validation branch FROM ho-prod
706
+ 7. Merge ALL patches already in release (from ho-release/X.Y.Z/* branches)
707
+ 8. Merge new patch branch (from ho-patch/{patch_id})
708
+ 9. Add patch to stage file on temp branch + commit
709
+ 10. Run validation tests (with ALL patches integrated)
710
+ 11. If tests fail: cleanup temp branch, release lock, exit with error
711
+ 12. If tests pass: return to ho-prod, delete temp branch
712
+ 13. Add patch to stage file on ho-prod + commit (file change only)
713
+ 14. Push ho-prod to origin
714
+ 16. Archive patch branch to ho-release/{version}/{patch_id}
715
+ 17. Release lock (in finally block)
716
+
717
+ CRITICAL: ho-prod NEVER contains patch code directly. It only contains
718
+ the releases/*.txt files that list which patches are in each release.
719
+ The temp-valid branch is used to test the integration of ALL patches
720
+ together, but only the release file change is committed to ho-prod.
721
+ Actual patch code remains in archived branches (ho-release/X.Y.Z/*).
722
+
723
+ Args:
724
+ patch_id: Patch identifier (e.g., "456-user-auth")
725
+ to_version: Optional explicit version (e.g., "1.3.6")
726
+ Required if multiple stage releases exist
727
+ Auto-detected if only one stage exists
728
+
729
+ Returns:
730
+ {
731
+ 'status': 'success',
732
+ 'patch_id': str, # "456-user-auth"
733
+ 'target_version': str, # "1.3.6"
734
+ 'stage_file': str, # "1.3.6-stage.txt"
735
+ 'temp_branch': str, # "temp-valid-1.3.6"
736
+ 'tests_passed': bool, # True
737
+ 'archived_branch': str, # "ho-release/1.3.6/456-user-auth"
738
+ 'commit_sha': str, # SHA of ho-prod commit
739
+ 'patches_in_release': List[str], # All patches after add
740
+ 'notifications_sent': List[str], # Branches notified
741
+ 'lock_tag': str # "lock-ho-prod-1704123456789"
742
+ }
743
+
744
+ Raises:
745
+ ReleaseManagerError: If validations fail:
746
+ - Not on ho-prod branch
747
+ - Repository not clean
748
+ - Patch doesn't exist (Patches/{patch_id}/)
749
+ - Branch doesn't exist (ho-patch/{patch_id})
750
+ - No stage release found
751
+ - Multiple stages without --to-version
752
+ - Specified stage doesn't exist
753
+ - Patch already in release
754
+ - Lock acquisition failed (another process holds lock)
755
+ - ho-prod diverged from origin
756
+ - Merge conflicts during integration
757
+ - Tests failed on temp branch
758
+ - Push failed
759
+
760
+ Examples:
761
+ # Add patch to auto-detected stage (one stage exists)
762
+ result = release_mgr.add_patch_to_release("456-user-auth")
763
+ # → Creates temp-valid-1.3.6
764
+ # → Merges all patches from releases/1.3.6-stage.txt
765
+ # → Merges ho-patch/456-user-auth
766
+ # → Tests complete integration
767
+ # → Updates releases/1.3.6-stage.txt on ho-prod
768
+ # → Archives to ho-release/1.3.6/456-user-auth
769
+
770
+ # Add patch to explicit version (multiple stages)
771
+ result = release_mgr.add_patch_to_release(
772
+ "456-user-auth",
773
+ to_version="1.3.6"
774
+ )
775
+
776
+ # Error handling
777
+ try:
778
+ result = release_mgr.add_patch_to_release("456-user-auth")
779
+ except ReleaseManagerError as e:
780
+ if "locked" in str(e):
781
+ print("Another add-to-release in progress, retry later")
782
+ elif "Tests failed" in str(e):
783
+ print("Patch breaks integration, fix and retry")
784
+ elif "Merge conflict" in str(e):
785
+ print("Patch conflicts with existing patches")
786
+ """
787
+ # 1. Pre-lock validations
788
+ if self._repo.hgit.branch != "ho-prod":
789
+ raise ReleaseManagerError(
790
+ "Must be on ho-prod branch to add patch to release.\n"
791
+ f"Current branch: {self._repo.hgit.branch}"
792
+ )
793
+
794
+ if not self._repo.hgit.repos_is_clean():
795
+ raise ReleaseManagerError(
796
+ "Repository has uncommitted changes. Commit or stash first."
797
+ )
798
+
799
+ # Check patch directory exists
800
+ patch_dir = Path(self._repo.base_dir) / "Patches" / patch_id
801
+ if not patch_dir.exists():
802
+ raise ReleaseManagerError(
803
+ f"Patch directory not found: Patches/{patch_id}/\n"
804
+ f"Create patch first with: half_orm dev create-patch"
805
+ )
806
+
807
+ # Check patch branch exists
808
+ if not self._repo.hgit.branch_exists(f"ho-patch/{patch_id}"):
809
+ raise ReleaseManagerError(
810
+ f"Branch ho-patch/{patch_id} not found locally.\n"
811
+ f"Checkout branch first: git checkout ho-patch/{patch_id}"
812
+ )
813
+
814
+ # 2. Detect target stage file
815
+ target_version, stage_file = self._detect_target_stage_file(to_version)
816
+
817
+ # 3. Check patch not already in release
818
+ existing_patches = self.read_release_patches(stage_file)
819
+ if patch_id in existing_patches:
820
+ raise ReleaseManagerError(
821
+ f"Patch {patch_id} already in release {target_version}-stage.\n"
822
+ f"Nothing to do."
823
+ )
824
+
825
+ # 4. ACQUIRE LOCK on ho-prod (with 30 min timeout for stale locks)
826
+ lock_tag = self._repo.hgit.acquire_branch_lock("ho-prod", timeout_minutes=30)
827
+
828
+ try:
829
+ sync_result = self._ensure_patch_branch_synced(patch_id)
830
+
831
+ if sync_result['strategy'] != 'already-synced':
832
+ # Log successful auto-sync
833
+ import sys
834
+ print(
835
+ f"✓ Auto-synced {sync_result['branch_name']} with ho-prod "
836
+ f"({sync_result['strategy']})",
837
+ file=sys.stderr
838
+ )
839
+
840
+ except ReleaseManagerError as e:
841
+ # Manual resolution required - release lock and exit
842
+ # Lock will be released in finally block
843
+ raise
844
+
845
+ temp_branch = f"temp-valid-{target_version}"
846
+
847
+ try:
848
+ # 5. Sync with origin (now that we have lock)
849
+ self._repo.hgit.fetch_from_origin()
850
+ is_synced, status = self._repo.hgit.is_branch_synced("ho-prod")
851
+
852
+ if status == "behind":
853
+ self._repo.hgit.pull()
854
+ elif status == "diverged":
855
+ raise ReleaseManagerError(
856
+ "Branch ho-prod has diverged from origin.\n"
857
+ "Manual merge or rebase required."
858
+ )
859
+
860
+ # 6. Create temporary validation branch FROM ho-prod
861
+ self._repo.hgit.checkout("-b", temp_branch)
862
+
863
+ # 7. Merge ALL existing patches in the release (already validated)
864
+ for existing_patch_id in existing_patches:
865
+ archived_branch = f"ho-release/{target_version}/{existing_patch_id}"
866
+ if self._repo.hgit.branch_exists(archived_branch):
867
+ try:
868
+ self._repo.hgit.merge(
869
+ archived_branch,
870
+ no_ff=True,
871
+ m=f"Merge {existing_patch_id} (already in release)"
872
+ )
873
+ except Exception as e:
874
+ # Should not happen (already validated), but handle it
875
+ self._repo.hgit.checkout("ho-prod")
876
+ self._repo.hgit._HGit__git_repo.git.branch("-D", temp_branch)
877
+ raise ReleaseManagerError(
878
+ f"Failed to merge existing patch {existing_patch_id}.\n"
879
+ f"This should not happen (patch already validated).\n"
880
+ f"Manual intervention required.\n"
881
+ f"Error: {e}"
882
+ )
883
+ else:
884
+ # Branch not found - might be an old patch before archiving system
885
+ import sys
886
+ sys.stderr.write(
887
+ f"Warning: Branch {archived_branch} not found. "
888
+ f"Patch {existing_patch_id} might be from old workflow.\n"
889
+ )
890
+
891
+ # 8. Merge new patch branch into temp-valid
892
+ try:
893
+ self._repo.hgit.merge(
894
+ f"ho-patch/{patch_id}",
895
+ no_ff=True,
896
+ m=f"Merge {patch_id} for validation in {target_version}-stage"
897
+ )
898
+ except Exception as e:
899
+ # Merge conflict - cleanup and exit
900
+ self._repo.hgit.checkout("ho-prod")
901
+ self._repo.hgit._HGit__git_repo.git.branch("-D", temp_branch)
902
+ raise ReleaseManagerError(
903
+ f"Merge conflict integrating {patch_id}.\n"
904
+ f"The patch conflicts with existing patches in the release.\n"
905
+ f"Resolve conflicts manually:\n"
906
+ f" 1. git checkout ho-patch/{patch_id}\n"
907
+ f" 2. git merge ho-prod\n"
908
+ f" 3. Resolve conflicts\n"
909
+ f" 4. half_orm dev apply-patch (re-test)\n"
910
+ f" 5. Retry add-to-release\n"
911
+ f"Error: {e}"
912
+ )
913
+
914
+ # 9. Add patch to stage file on temp branch
915
+ self._apply_patch_change_to_stage_file(stage_file, patch_id)
916
+
917
+ # 10. Commit release file on temp branch
918
+ commit_msg = f"Add {patch_id} to release {target_version}-stage (validation)"
919
+ self._repo.hgit.add(str(self._releases_dir / stage_file))
920
+ self._repo.hgit.commit("-m", commit_msg)
921
+
922
+ # 11. Run validation tests (ALL patches integrated)
923
+ try:
924
+ self._run_validation_tests()
925
+ except ReleaseManagerError as e:
926
+ # Tests failed - cleanup and exit
927
+ self._repo.hgit.checkout("ho-prod")
928
+ self._repo.hgit._HGit__git_repo.git.branch("-D", temp_branch)
929
+ raise ReleaseManagerError(
930
+ f"Tests failed for patch {patch_id}. Not integrated.\n"
931
+ f"The patch breaks the integration with existing patches.\n"
932
+ f"{e}"
933
+ )
934
+
935
+ # 12. Tests passed! Return to ho-prod
936
+ self._repo.hgit.checkout("ho-prod")
937
+
938
+ # 13. Delete temp branch (validation complete, no longer needed)
939
+ self._repo.hgit._HGit__git_repo.git.branch("-D", temp_branch)
940
+
941
+ # 14. Add patch to stage file on ho-prod (file change ONLY)
942
+ self._apply_patch_change_to_stage_file(stage_file, patch_id)
943
+
944
+ # 15. Commit on ho-prod (only release file change)
945
+ commit_msg = f"Add {patch_id} to release {target_version}-stage"
946
+ self._repo.hgit.add(str(self._releases_dir / stage_file))
947
+ self._repo.hgit.commit("-m", commit_msg)
948
+ commit_sha = self._repo.hgit.last_commit()
949
+
950
+ # 16. Push ho-prod (no conflict possible - we have lock)
951
+ self._repo.hgit.push("origin", "ho-prod")
952
+
953
+ # 18. Archive patch branch to ho-release namespace
954
+ archived_branch = f"ho-release/{target_version}/{patch_id}"
955
+ self._repo.hgit.rename_branch(
956
+ f"ho-patch/{patch_id}",
957
+ archived_branch,
958
+ delete_remote_old=True
959
+ )
960
+
961
+ # 19. Read final patch list
962
+ final_patches = self.read_release_patches(stage_file)
963
+
964
+ return {
965
+ 'status': 'success',
966
+ 'patch_id': patch_id,
967
+ 'target_version': target_version,
968
+ 'stage_file': stage_file,
969
+ 'temp_branch': temp_branch,
970
+ 'tests_passed': True,
971
+ 'archived_branch': archived_branch,
972
+ 'commit_sha': commit_sha,
973
+ 'patches_in_release': final_patches,
974
+ 'lock_tag': lock_tag
975
+ }
976
+
977
+ finally:
978
+ # 20. ALWAYS release lock (even on error)
979
+ self._repo.hgit.release_branch_lock(lock_tag)
980
+
981
+
982
+ def _detect_target_stage_file(self, to_version: Optional[str] = None) -> Tuple[str, str]:
983
+ """
984
+ Detect target stage file (auto-detect or explicit).
985
+
986
+ Logic:
987
+ - If to_version provided: validate it exists
988
+ - If no to_version: auto-detect (error if 0 or multiple stages)
989
+
990
+ Args:
991
+ to_version: Optional explicit version (e.g., "1.3.6")
992
+
993
+ Returns:
994
+ Tuple of (version, filename)
995
+ Example: ("1.3.6", "1.3.6-stage.txt")
996
+
997
+ Raises:
998
+ ReleaseManagerError:
999
+ - No stage release found (need prepare-release first)
1000
+ - Multiple stages without explicit version
1001
+ - Specified stage doesn't exist
1002
+
1003
+ Examples:
1004
+ # Auto-detect (one stage exists)
1005
+ version, filename = self._detect_target_stage_file()
1006
+ # Returns: ("1.3.6", "1.3.6-stage.txt")
1007
+
1008
+ # Explicit version
1009
+ version, filename = self._detect_target_stage_file("1.4.0")
1010
+ # Returns: ("1.4.0", "1.4.0-stage.txt")
1011
+
1012
+ # Error cases
1013
+ # No stage: "No stage release found. Run 'prepare-release' first."
1014
+ # Multiple stages: "Multiple stages found. Use --to-version."
1015
+ # Invalid: "Stage release 1.9.9 not found"
1016
+ """
1017
+ # Find all stage files
1018
+ stage_files = list(self._releases_dir.glob("*-stage.txt"))
1019
+
1020
+ # Multiple stages: require explicit version
1021
+ if len(stage_files) > 1 and not to_version:
1022
+ versions = sorted([str(self.parse_version_from_filename(f.name)).replace('-stage', '') for f in stage_files])
1023
+ err_msg = "\n".join([f"Multiple stage releases found: {', '.join(versions)}",
1024
+ f"Specify target version:",
1025
+ f" half_orm dev promote-to rc --to-version=<version>"])
1026
+ raise ReleaseManagerError(err_msg)
1027
+
1028
+
1029
+ # If explicit version provided
1030
+ if to_version:
1031
+ stage_file = self._releases_dir / f"{to_version}-stage.txt"
1032
+
1033
+ if not stage_file.exists():
1034
+ raise ReleaseManagerError(
1035
+ f"Stage release {to_version} not found.\n"
1036
+ f"Available stages: {[f.stem for f in stage_files]}"
1037
+ )
1038
+
1039
+ return (to_version, f"{to_version}-stage.txt")
1040
+
1041
+ # Auto-detect
1042
+ if len(stage_files) == 0:
1043
+ raise ReleaseManagerError(
1044
+ "No stage release found.\n"
1045
+ "Run 'half_orm dev prepare-release <type>' first."
1046
+ )
1047
+
1048
+ if len(stage_files) > 1:
1049
+ versions = [f.stem.replace('-stage', '') for f in stage_files]
1050
+ raise ReleaseManagerError(
1051
+ f"Multiple stage releases found: {versions}\n"
1052
+ f"Use --to-version to specify target release."
1053
+ )
1054
+
1055
+ # Single stage file
1056
+ stage_file = stage_files[0]
1057
+ version = stage_file.stem.replace('-stage', '')
1058
+
1059
+ return (version, stage_file.name)
1060
+
1061
+
1062
+ def _get_active_patch_branches(self) -> List[str]:
1063
+ """
1064
+ Get list of all active ho-patch/* branches from remote.
1065
+
1066
+ Reads remote refs after fetch to find all branches matching
1067
+ the ho-patch/* pattern. Used for sending resync notifications.
1068
+
1069
+ Prerequisite: fetch_from_origin() must be called first to have
1070
+ up-to-date remote refs.
1071
+
1072
+ Returns:
1073
+ List of branch names (e.g., ["ho-patch/456-user-auth", "ho-patch/789-security"])
1074
+ Empty list if no patch branches exist
1075
+
1076
+ Examples:
1077
+ # Get active patch branches
1078
+ branches = self._get_active_patch_branches()
1079
+ # Returns: [
1080
+ # "ho-patch/456-user-auth",
1081
+ # "ho-patch/789-security",
1082
+ # "ho-patch/234-reports"
1083
+ # ]
1084
+
1085
+ # Used for notifications
1086
+ for branch in self._get_active_patch_branches():
1087
+ if branch != f"ho-patch/{current_patch_id}":
1088
+ # Send notification to this branch
1089
+ ...
1090
+ """
1091
+ git_repo = self._repo.hgit._HGit__git_repo
1092
+
1093
+ try:
1094
+ remote = git_repo.remote('origin')
1095
+ except Exception:
1096
+ return [] # No remote or remote not accessible
1097
+
1098
+ pattern = "origin/ho-patch/*"
1099
+
1100
+ branches = [
1101
+ ref.name.replace('origin/', '', 1)
1102
+ for ref in remote.refs
1103
+ if fnmatch.fnmatch(ref.name, pattern)
1104
+ ]
1105
+
1106
+ return branches
1107
+
1108
+ def _send_rebase_notifications(
1109
+ self,
1110
+ version: str,
1111
+ release_type: str,
1112
+ rc_number: int = None) -> List[str]:
1113
+ """
1114
+ Send merge notifications to all active patch branches.
1115
+
1116
+ After code is merged to ho-prod (promote-to rc or promote-to prod),
1117
+ active development branches must merge changes from ho-prod.
1118
+ This sends notifications (empty commits) to all ho-patch/* branches.
1119
+
1120
+ Note: We use "merge" not "rebase" because branches are shared between
1121
+ developers. Rebase would rewrite history and cause conflicts.
1122
+
1123
+ Args:
1124
+ version: Version string (e.g., "1.3.5")
1125
+ release_type: one of ['alpha', 'beta', 'rc', 'prod']
1126
+ rc_number: RC number (required if release_type != 'prod')
1127
+
1128
+ Returns:
1129
+ List[str]: Notified branch names (without origin/ prefix)
1130
+
1131
+ Examples:
1132
+ # RC promotion
1133
+ notified = mgr._send_rebase_notifications("1.3.5", 'rc', rc_number=1)
1134
+ # → Message: "[ho] 1.3.5-rc1 promoted (MERGE REQUIRED)"
1135
+
1136
+ # Production deployment
1137
+ notified = mgr._send_rebase_notifications("1.3.5", 'prod')
1138
+ # → Message: "[ho] Production 1.3.5 deployed (MERGE REQUIRED)"
1139
+ """
1140
+ # Get all active patch branches
1141
+ remote_branches = self._repo.hgit.get_remote_branches()
1142
+
1143
+ # Filter for active ho-patch/* branches
1144
+ active_branches = []
1145
+ for branch in remote_branches:
1146
+ # Strip 'origin/' prefix if present
1147
+ branch_name = branch.replace("origin/", "")
1148
+
1149
+ # Only include ho-patch/* branches
1150
+ if branch_name.startswith("ho-patch/"):
1151
+ active_branches.append(branch_name)
1152
+
1153
+ if not active_branches:
1154
+ return []
1155
+
1156
+ notified_branches = []
1157
+ current_branch = self._repo.hgit.branch
1158
+
1159
+ # Build release identifier for message
1160
+ if release_type and release_type != 'prod':
1161
+ if rc_number is None:
1162
+ rc_number = ''
1163
+ release_id = f"{version}-{release_type}{rc_number}"
1164
+ event = "promoted"
1165
+ else: # prod
1166
+ release_id = f"production {version}"
1167
+ event = "deployed"
1168
+
1169
+ for branch in active_branches:
1170
+ try:
1171
+ # Checkout branch
1172
+ self._repo.hgit.checkout(branch)
1173
+
1174
+ # Create notification message
1175
+ message = (
1176
+ f"[ho] {release_id.capitalize()} {event} (MERGE REQUIRED)\n\n"
1177
+ f"Version {release_id} has been {event} with code merged to ho-prod.\n"
1178
+ f"Active patch branches MUST merge these changes.\n\n"
1179
+ f"Action required (branches are shared):\n"
1180
+ f" git checkout {branch}\n"
1181
+ f" git pull # Get this notification\n"
1182
+ f" git merge ho-prod\n"
1183
+ f" # Resolve conflicts if any\n"
1184
+ f" git push\n\n"
1185
+ f"Status: Action required (merge from ho-prod)"
1186
+ )
1187
+
1188
+ # Create empty commit with notification
1189
+ self._repo.hgit.commit("--allow-empty", "-m", message)
1190
+
1191
+ # Push notification
1192
+ self._repo.hgit.push()
1193
+
1194
+ notified_branches.append(branch)
1195
+
1196
+ except Exception as e:
1197
+ # Non-blocking: continue with other branches
1198
+ print(f"Warning: Failed to notify {branch}: {e}")
1199
+ continue
1200
+
1201
+ # Return to original branch
1202
+ self._repo.hgit.checkout(current_branch)
1203
+
1204
+ return notified_branches
1205
+
1206
+ def _run_validation_tests(self) -> None:
1207
+ """
1208
+ Run pytest tests on current branch for validation.
1209
+
1210
+ Executes pytest in tests/ directory and checks return code.
1211
+ Used to validate patch integration on temporary branch before
1212
+ committing to ho-prod.
1213
+
1214
+ Prerequisite: Must be on temp validation branch with patch
1215
+ applied and code generated.
1216
+
1217
+ Raises:
1218
+ ReleaseManagerError: If tests fail (non-zero exit code)
1219
+ Error message includes pytest output for debugging
1220
+
1221
+ Examples:
1222
+ # On temp-valid-1.3.6 after applying patches
1223
+ try:
1224
+ self._run_validation_tests()
1225
+ print("✅ All tests passed")
1226
+ except ReleaseManagerError as e:
1227
+ print(f"❌ Tests failed:\n{e}")
1228
+ # Cleanup and exit
1229
+ """
1230
+ try:
1231
+ result = subprocess.run(
1232
+ ["pytest", "tests/"],
1233
+ cwd=str(self._repo.base_dir),
1234
+ capture_output=True,
1235
+ text=True
1236
+ )
1237
+
1238
+ if result.returncode != 0:
1239
+ raise ReleaseManagerError(
1240
+ f"Tests failed for patch integration:\n"
1241
+ f"{result.stdout}\n"
1242
+ f"{result.stderr}"
1243
+ )
1244
+
1245
+ except FileNotFoundError:
1246
+ raise ReleaseManagerError(
1247
+ "pytest not found. Install pytest to run validation tests."
1248
+ )
1249
+ except subprocess.TimeoutExpired:
1250
+ raise ReleaseManagerError(
1251
+ "Tests timed out. Check for hanging tests."
1252
+ )
1253
+ except Exception as e:
1254
+ raise ReleaseManagerError(
1255
+ f"Failed to run tests: {e}"
1256
+ )
1257
+
1258
+
1259
+
1260
+ def _apply_patch_change_to_stage_file(
1261
+ self,
1262
+ stage_file: str,
1263
+ patch_id: str
1264
+ ) -> None:
1265
+ """
1266
+ Add patch ID to stage release file (append to end).
1267
+
1268
+ Appends patch_id as new line at end of releases/{stage_file}.
1269
+ Creates file if it doesn't exist (should not happen in normal flow).
1270
+
1271
+ Does NOT commit - caller is responsible for staging and committing.
1272
+
1273
+ Args:
1274
+ stage_file: Stage filename (e.g., "1.3.6-stage.txt")
1275
+ patch_id: Patch identifier to add (e.g., "456-user-auth")
1276
+
1277
+ Raises:
1278
+ ReleaseManagerError: If file write fails
1279
+
1280
+ Examples:
1281
+ # Add patch to stage file
1282
+ self._apply_patch_change_to_stage_file("1.3.6-stage.txt", "456-user-auth")
1283
+
1284
+ # File content before:
1285
+ # 123-initial
1286
+ # 789-security
1287
+
1288
+ # File content after:
1289
+ # 123-initial
1290
+ # 789-security
1291
+ # 456-user-auth
1292
+
1293
+ # Caller must then:
1294
+ # self._repo.hgit.add("releases/1.3.6-stage.txt")
1295
+ # self._repo.hgit.commit("-m", "Add 456-user-auth to release")
1296
+ """
1297
+ stage_path = self._releases_dir / stage_file
1298
+
1299
+ try:
1300
+ # Append patch to file (create if doesn't exist)
1301
+ with open(stage_path, 'a', encoding='utf-8') as f:
1302
+ f.write(f"{patch_id}\n")
1303
+
1304
+ except Exception as e:
1305
+ raise ReleaseManagerError(
1306
+ f"Failed to update stage file {stage_file}: {e}"
1307
+ )
1308
+
1309
+ def promote_to(self, target: str) -> dict:
1310
+ """
1311
+ Unified promotion workflow for RC and production releases.
1312
+
1313
+ Handles promotion of stage releases to either RC or production with
1314
+ shared logic for validations, lock management, code merging, branch
1315
+ cleanup, and notifications. Target-specific operations (RC numbering,
1316
+ schema generation) are conditionally executed.
1317
+
1318
+ Args:
1319
+ target: Either 'rc' or 'prod'
1320
+ - 'rc': Promotes stage to RC (rc1, rc2, etc.)
1321
+ - 'prod': Promotes stage (or empty) to production
1322
+
1323
+ Returns:
1324
+ dict: Promotion result with target-specific fields
1325
+
1326
+ Common fields:
1327
+ 'status': 'success'
1328
+ 'version': str (e.g., "1.3.5")
1329
+ 'from_file': str or None (source filename)
1330
+ 'to_file': str (target filename)
1331
+ 'patches_merged': List[str] (merged patch IDs)
1332
+ 'branches_deleted': List[str] (deleted branch names)
1333
+ 'commit_sha': str
1334
+ 'notifications_sent': List[str] (notified branches)
1335
+ 'lock_tag': str
1336
+
1337
+ RC-specific fields (target='rc'):
1338
+ 'rc_number': int (e.g., 1, 2, 3)
1339
+ 'code_merged': bool (always True)
1340
+
1341
+ Production-specific fields (target='prod'):
1342
+ 'patches_applied': List[str] (all patches applied to DB)
1343
+ 'schema_file': Path (model/schema-X.Y.Z.sql)
1344
+ 'metadata_file': Path (model/metadata-X.Y.Z.sql)
1345
+
1346
+ Raises:
1347
+ ReleaseManagerError: For validation failures, lock errors, etc.
1348
+ ValueError: If target is not 'rc' or 'prod'
1349
+
1350
+ Workflow:
1351
+ 0. restore prod database (schema & metadata)
1352
+ 1. Pre-lock validations (ho-prod branch, clean repo)
1353
+ 2. Detect source and target (version-specific logic)
1354
+ 3. ACQUIRE DISTRIBUTED LOCK (30min timeout)
1355
+ 4. Fetch + sync with origin
1356
+ 5. [PROD ONLY] Restore DB and apply all patches
1357
+ 6. Merge archived patch code to ho-prod
1358
+ 7. Create target release file (mv or create)
1359
+ 8. [PROD ONLY] Generate schema + metadata + symlink
1360
+ 9. Commit + push
1361
+ 10. Push to origin
1362
+ 10.5 Create new empty stage file
1363
+ 11. Send rebase notifications
1364
+ 12. Cleanup patch branches
1365
+ 13. RELEASE LOCK (always, even on error)
1366
+
1367
+ Examples:
1368
+ # Promote to RC
1369
+ result = mgr.promote_to(target='rc')
1370
+ # → Creates X.Y.Z-rc2.txt from X.Y.Z-stage.txt
1371
+ # → Merges code, cleans branches, sends notifications
1372
+
1373
+ # Promote to production
1374
+ result = mgr.promote_to(target='prod')
1375
+ # → Creates X.Y.Z.txt from X.Y.Z-stage.txt (or empty)
1376
+ # → Applies all patches to DB
1377
+ # → Generates schema-X.Y.Z.sql + metadata-X.Y.Z.sql
1378
+ # → Merges code, cleans branches, sends notifications
1379
+ """
1380
+ # Validate target parameter
1381
+ if target not in ('alpha', 'beta', 'rc', 'prod'):
1382
+ raise ValueError(f"Invalid target: {target}. Must be in ['alpha', 'beta', 'rc', 'prod']")
1383
+
1384
+ # 0. restore database to prod
1385
+ self._repo.restore_database_from_schema()
1386
+
1387
+ # 1. Pre-lock validations (common)
1388
+ if self._repo.hgit.branch != "ho-prod":
1389
+ raise ReleaseManagerError(
1390
+ "Must be on ho-prod branch to promote release. "
1391
+ f"Current branch: {self._repo.hgit.branch}"
1392
+ )
1393
+
1394
+ if not self._repo.hgit.repos_is_clean():
1395
+ raise ReleaseManagerError(
1396
+ "Repository has uncommitted changes. "
1397
+ "Commit or stash changes before promoting."
1398
+ )
1399
+
1400
+ # 2. Detect source and target (target-specific)
1401
+ version, stage_file = self._detect_stage_to_promote()
1402
+ if target != 'prod':
1403
+ # RC: stage required, validate single active RC rule
1404
+ self._validate_single_active_rc(version)
1405
+ rc_number = self._determine_rc_number(version)
1406
+ target_file = f"{version}-{target}{rc_number}.txt"
1407
+ source_type = 'stage'
1408
+ else: # target == 'prod'
1409
+ # Production: stage optional, sequential version
1410
+ stage_path = Path(self._releases_dir) / f"{version}-stage.txt"
1411
+ if stage_path.exists():
1412
+ stage_file = f"{version}-stage.txt"
1413
+ source_type = 'stage'
1414
+ else:
1415
+ stage_file = None
1416
+ source_type = 'empty'
1417
+ target_file = f"{version}.txt"
1418
+
1419
+ # 3. Acquire distributed lock
1420
+ lock_tag = None
1421
+ try:
1422
+ lock_tag = self._repo.hgit.acquire_branch_lock("ho-prod", timeout_minutes=30)
1423
+
1424
+ # 4. Fetch from origin and sync
1425
+ self._repo.hgit.fetch_from_origin()
1426
+
1427
+ is_synced, sync_status = self._repo.hgit.is_branch_synced("ho-prod")
1428
+ if not is_synced:
1429
+ if sync_status == "behind":
1430
+ self._repo.hgit.pull()
1431
+ elif sync_status == "diverged":
1432
+ raise ReleaseManagerError(
1433
+ "ho-prod has diverged from origin. "
1434
+ "Resolve conflicts manually: git pull origin ho-prod"
1435
+ )
1436
+
1437
+ # 5. Apply patches to database (prod only)
1438
+ patches_applied = []
1439
+ if target == 'prod':
1440
+ patches_applied = self._restore_and_apply_all_patches(version)
1441
+
1442
+ # 6. Merge archived patches code into ho-prod (common)
1443
+ if stage_file:
1444
+ patches_merged = self._merge_archived_patches_to_ho_prod(version, stage_file)
1445
+ else:
1446
+ patches_merged = []
1447
+
1448
+ # 7. Create target release file
1449
+ stage_path = self._releases_dir / stage_file if stage_file else None
1450
+ target_path = self._releases_dir / target_file
1451
+
1452
+ if stage_file:
1453
+ # Rename stage to target (git mv)
1454
+ self._repo.hgit.mv(str(stage_path), str(target_path))
1455
+ else:
1456
+ # Create empty production file (prod only)
1457
+ target_path.touch()
1458
+ self._repo.hgit.add(str(target_path))
1459
+
1460
+ # 8. Generate schema and metadata (prod only)
1461
+ schema_info = {}
1462
+ if target == 'prod':
1463
+ schema_info = self._generate_schema_and_metadata(version)
1464
+ # Add generated files to commit
1465
+ self._repo.hgit.add(str(schema_info['schema_file']))
1466
+ self._repo.hgit.add(str(schema_info['metadata_file']))
1467
+ self._repo.hgit.add(str(self._repo.base_dir / Path("model") / "schema.sql"))
1468
+
1469
+ # 9. Commit promotion
1470
+ full_version = version
1471
+ if target != 'prod':
1472
+ full_version = f"{version}-rc{rc_number}"
1473
+ commit_message = f"Promote {version}-stage to {full_version}"
1474
+ else:
1475
+ commit_message = f"Promote {version}-stage to production release {version}"
1476
+
1477
+ self._repo.hgit.add(str(target_path))
1478
+ self._repo.hgit.commit("-m", commit_message)
1479
+ commit_sha = self._repo.hgit.last_commit()
1480
+
1481
+ # 10. Push to origin
1482
+ self._repo.hgit.push()
1483
+ # Create and push Git tag for new release
1484
+ tag_name = f"v{full_version}"
1485
+ self._repo.hgit.create_tag(tag_name, f"Release {full_version}")
1486
+ self._repo.hgit.push_tag(tag_name)
1487
+
1488
+ # 10.5 Create new empty stage file ONLY for RC promotion
1489
+ new_stage_filename = None
1490
+ new_stage_commit_sha = None
1491
+
1492
+ if target != 'prod':
1493
+ # Pour RC : on peut continuer à travailler sur la même version (rc2, rc3...)
1494
+ new_stage_filename = f"{version}-stage.txt"
1495
+ new_stage_path = self._releases_dir / new_stage_filename
1496
+ new_stage_path.write_text("")
1497
+
1498
+ # Add + commit + push
1499
+ self._repo.hgit.add(new_stage_path)
1500
+ commit_msg = f"Create new empty stage file for {version}"
1501
+ new_stage_commit_sha = self._repo.hgit.commit('-m', commit_msg)
1502
+ self._repo.hgit.push()
1503
+
1504
+ # 11. Send rebase notifications to active branches
1505
+ if target != 'prod':
1506
+ notifications_sent = self._send_rebase_notifications(version, target, rc_number=rc_number)
1507
+ else:
1508
+ notifications_sent = self._send_rebase_notifications(version, target)
1509
+
1510
+ # 12. Cleanup patch branches
1511
+ if stage_file:
1512
+ branches_deleted = self._cleanup_patch_branches(version, stage_file)
1513
+ else:
1514
+ branches_deleted = []
1515
+
1516
+ # Build result dict (common fields)
1517
+ result = {
1518
+ 'status': 'success',
1519
+ 'version': version,
1520
+ 'from_file': stage_file,
1521
+ 'to_file': target_file,
1522
+ 'patches_merged': patches_merged,
1523
+ 'branches_deleted': branches_deleted,
1524
+ 'commit_sha': commit_sha,
1525
+ 'notifications_sent': notifications_sent,
1526
+ 'lock_tag': lock_tag,
1527
+ 'new_stage_created': new_stage_filename,
1528
+ 'tag_name': tag_name
1529
+ }
1530
+
1531
+ # Add target-specific fields
1532
+ if target != 'prod':
1533
+ result['rc_number'] = rc_number
1534
+ result['code_merged'] = True
1535
+ else:
1536
+ result['source_type'] = source_type
1537
+ result['patches_applied'] = patches_applied
1538
+ result.update(schema_info)
1539
+
1540
+ return result
1541
+
1542
+ finally:
1543
+ # 13. Always release lock (even on error)
1544
+ if lock_tag:
1545
+ self._repo.hgit.release_branch_lock(lock_tag)
1546
+
1547
+
1548
+ def _get_next_production_version(self) -> str:
1549
+ """
1550
+ Get next sequential production version.
1551
+
1552
+ Calculates the next patch version after current production.
1553
+ Used by promote-to prod to determine target version.
1554
+
1555
+ Returns:
1556
+ str: Next version (e.g., "1.3.5" if current is "1.3.4")
1557
+
1558
+ Raises:
1559
+ ReleaseManagerError: If cannot determine production version
1560
+
1561
+ Examples:
1562
+ # Current production: 1.3.4
1563
+ next_ver = mgr._get_next_production_version()
1564
+ # → "1.3.5"
1565
+ """
1566
+ rc_files = list(self._releases_dir.glob("*-rc*.txt"))
1567
+
1568
+ if rc_files:
1569
+ # Use version from RC (without -rcN suffix)
1570
+ # There should be only one due to single active RC rule
1571
+ rc_file = rc_files[0]
1572
+ version = self.parse_version_from_filename(rc_file.name)
1573
+ return re.sub('-.*', '', str(version))
1574
+
1575
+ # No RC exists: increment from current production
1576
+ # This handles edge case of direct prod promotion without RC
1577
+ current_prod = self._get_production_version()
1578
+ current_version = self.parse_version_from_filename(f"{current_prod}.txt")
1579
+ return self.calculate_next_version(current_version, 'patch')
1580
+
1581
+
1582
+ def _restore_and_apply_all_patches(self, version: str) -> List[str]:
1583
+ """
1584
+ Restore database and apply all patches sequentially.
1585
+
1586
+ Used by promote-to prod to prepare database before schema dump.
1587
+ Restores DB from current schema.sql, then applies all patches
1588
+ from RC files and stage file in order.
1589
+
1590
+ Args:
1591
+ version: Target version (e.g., "1.3.5")
1592
+
1593
+ Returns:
1594
+ List[str]: Patch IDs applied (in order)
1595
+
1596
+ Examples:
1597
+ # Files: 1.3.5-rc1.txt [10], 1.3.5-rc2.txt [42, 12], 1.3.5-stage.txt [18]
1598
+ patches = mgr._restore_and_apply_all_patches("1.3.5")
1599
+ # → Returns ["10", "42", "12", "18"]
1600
+ # → Database now at state with all patches applied
1601
+ """
1602
+ # 1. Restore database to current production state
1603
+ self._repo.restore_database_from_schema()
1604
+
1605
+ # 2. Get all patches for this version (RC1 + RC2 + ... + stage)
1606
+ all_patches = self.get_all_release_context_patches()
1607
+
1608
+ # 3. Apply each patch sequentially
1609
+ for patch_id in all_patches:
1610
+ self._repo.patch_manager.apply_patch_files(patch_id, self._repo.model)
1611
+
1612
+ # 4. Update database version to target production version
1613
+ # CRITICAL: Must be done before generating schema dumps
1614
+ self._repo.database.register_release(*version.split('.'))
1615
+
1616
+ return all_patches
1617
+
1618
+
1619
+ def _generate_schema_and_metadata(self, version: str) -> dict:
1620
+ """
1621
+ Generate schema and metadata dumps, update symlink.
1622
+
1623
+ Generates schema-X.Y.Z.sql and metadata-X.Y.Z.sql files via pg_dump,
1624
+ then updates schema.sql symlink to point to new version.
1625
+
1626
+ Args:
1627
+ version: Version string (e.g., "1.3.5")
1628
+
1629
+ Returns:
1630
+ dict: Generated file paths
1631
+ 'schema_file': Path to schema-X.Y.Z.sql
1632
+ 'metadata_file': Path to metadata-X.Y.Z.sql
1633
+
1634
+ Raises:
1635
+ Exception: If pg_dump fails or file operations fail
1636
+
1637
+ Examples:
1638
+ info = mgr._generate_schema_and_metadata("1.3.5")
1639
+ # → Creates model/schema-1.3.5.sql
1640
+ # → Creates model/metadata-1.3.5.sql
1641
+ # → Updates model/schema.sql → schema-1.3.5.sql
1642
+ # → Returns {'schema_file': Path(...), 'metadata_file': Path(...)}
1643
+ """
1644
+ from half_orm_dev.database import Database
1645
+
1646
+ model_dir = Path(self._repo.base_dir) / "model"
1647
+
1648
+ # Database._generate_schema_sql() creates both schema and metadata
1649
+ schema_file = Database._generate_schema_sql(
1650
+ self._repo.database,
1651
+ version,
1652
+ model_dir
1653
+ )
1654
+ metadata_file = model_dir / f"metadata-{version}.sql"
1655
+
1656
+ return {
1657
+ 'schema_file': schema_file,
1658
+ 'metadata_file': metadata_file
1659
+ }
1660
+
1661
+
1662
+ def _detect_stage_to_promote(self) -> Tuple[str, str]:
1663
+ """
1664
+ Detect smallest stage release to promote.
1665
+
1666
+ Finds all *-stage.txt files, parses versions, and returns the smallest
1667
+ version. This ensures sequential promotion (cannot skip versions).
1668
+
1669
+ Algorithm:
1670
+ 1. List all releases/*-stage.txt files
1671
+ 2. Parse version from each filename (e.g., "1.3.5-stage.txt" → "1.3.5")
1672
+ 3. Sort versions in ascending order
1673
+ 4. Return smallest version and filename
1674
+
1675
+ Returns:
1676
+ Tuple of (version, stage_filename)
1677
+ Example: ("1.3.5", "1.3.5-stage.txt")
1678
+
1679
+ Raises:
1680
+ ReleaseManagerError: If no stage releases found
1681
+
1682
+ Examples:
1683
+ # Single stage
1684
+ releases/1.3.5-stage.txt exists
1685
+ version, filename = mgr._detect_stage_to_promote()
1686
+ # → ("1.3.5", "1.3.5-stage.txt")
1687
+
1688
+ # Multiple stages (returns smallest)
1689
+ releases/1.3.5-stage.txt, 1.4.0-stage.txt, 2.0.0-stage.txt exist
1690
+ version, filename = mgr._detect_stage_to_promote()
1691
+ # → ("1.3.5", "1.3.5-stage.txt")
1692
+
1693
+ # No stages
1694
+ version, filename = mgr._detect_stage_to_promote()
1695
+ # → Raises: "No stage releases found. Create one with prepare-release"
1696
+ """
1697
+ # List all stage files
1698
+ stage_files = list(self._releases_dir.glob("*-stage.txt"))
1699
+
1700
+ if not stage_files:
1701
+ raise ReleaseManagerError(
1702
+ "No stage releases found. "
1703
+ "Create a stage release first with: half_orm dev prepare-release"
1704
+ )
1705
+
1706
+ # Parse versions and sort
1707
+ stage_versions = []
1708
+ for stage_file in stage_files:
1709
+ # Extract version from filename (e.g., "1.3.5-stage.txt" → "1.3.5")
1710
+ version_str = stage_file.name.replace("-stage.txt", "")
1711
+ version = self.parse_version_from_filename(stage_file.name)
1712
+ stage_versions.append((version, version_str, stage_file.name))
1713
+
1714
+ # Sort by version (ascending)
1715
+ stage_versions.sort(key=lambda x: (x[0].major, x[0].minor, x[0].patch))
1716
+
1717
+ # Return smallest version
1718
+ smallest = stage_versions[0]
1719
+ return smallest[1], smallest[2] # (version_str, filename)
1720
+
1721
+
1722
+ def _validate_single_active_rc(self, stage_version: str) -> None:
1723
+ """
1724
+ Validate single active RC rule.
1725
+
1726
+ Ensures only one version level is in RC at a time. The rule allows:
1727
+ - No RC exists → OK (promoting first RC)
1728
+ - RC of SAME version exists → OK (rc1 → rc2 → rc3)
1729
+ - RC of DIFFERENT version exists → ERROR (must deploy first)
1730
+
1731
+ Args:
1732
+ stage_version: Version being promoted (e.g., "1.3.5")
1733
+
1734
+ Raises:
1735
+ ReleaseManagerError: If different version RC exists
1736
+
1737
+ Examples:
1738
+ # No RC exists - OK
1739
+ stage_version = "1.3.5"
1740
+ mgr._validate_single_active_rc(stage_version)
1741
+ # → No error
1742
+
1743
+ # Same version RC exists - OK
1744
+ releases/1.3.5-rc1.txt exists
1745
+ stage_version = "1.3.5"
1746
+ mgr._validate_single_active_rc(stage_version)
1747
+ # → No error (promoting to rc2)
1748
+
1749
+ # Different version RC exists - ERROR
1750
+ releases/1.3.5-rc1.txt exists
1751
+ stage_version = "1.4.0"
1752
+ mgr._validate_single_active_rc(stage_version)
1753
+ # → Raises: "Cannot promote 1.4.0-stage, RC 1.3.5-rc1 must be deployed first"
1754
+
1755
+ # Multiple RCs of same version - OK
1756
+ releases/1.3.5-rc1.txt, 1.3.5-rc2.txt exist
1757
+ stage_version = "1.3.5"
1758
+ mgr._validate_single_active_rc(stage_version)
1759
+ # → No error (promoting to rc3)
1760
+ """
1761
+ # List all RC files
1762
+ rc_files = list(self._releases_dir.glob("*-rc*.txt"))
1763
+
1764
+ if not rc_files:
1765
+ # No RC exists, promotion allowed
1766
+ return
1767
+
1768
+ # Check if any RC is of a different version
1769
+ for rc_file in rc_files:
1770
+ # Extract version from RC filename (e.g., "1.3.5-rc1.txt" → "1.3.5")
1771
+ rc_filename = rc_file.name
1772
+ # Remove "-rcN.txt" suffix to get version
1773
+ rc_version = rc_filename.split("-rc")[0]
1774
+
1775
+ if rc_version != stage_version:
1776
+ # Different version RC exists, block promotion
1777
+ raise ReleaseManagerError(
1778
+ f"Cannot promote {stage_version}-stage to RC: "
1779
+ f"RC {rc_filename.replace('.txt', '')} must be deployed to production first. "
1780
+ f"Only one version can be in RC at a time."
1781
+ )
1782
+
1783
+ # All RCs are same version as stage, promotion allowed
1784
+
1785
+
1786
+ def _determine_rc_number(self, version: str) -> int:
1787
+ """
1788
+ Determine next RC number for version.
1789
+
1790
+ Finds all existing RC files for the version and returns next number.
1791
+ If no RCs exist, returns 1. If rc1, rc2 exist, returns 3.
1792
+
1793
+ Args:
1794
+ version: Version string (e.g., "1.3.5")
1795
+
1796
+ Returns:
1797
+ Next RC number (1, 2, 3, etc.)
1798
+
1799
+ Examples:
1800
+ # No existing RCs
1801
+ version = "1.3.5"
1802
+ rc_num = mgr._determine_rc_number(version)
1803
+ # → 1
1804
+
1805
+ # rc1 exists
1806
+ releases/1.3.5-rc1.txt exists
1807
+ rc_num = mgr._determine_rc_number(version)
1808
+ # → 2
1809
+
1810
+ # rc1, rc2, rc3 exist
1811
+ releases/1.3.5-rc1.txt, 1.3.5-rc2.txt, 1.3.5-rc3.txt exist
1812
+ rc_num = mgr._determine_rc_number(version)
1813
+ # → 4
1814
+
1815
+ Note:
1816
+ Uses get_rc_files() which returns sorted RC files for version.
1817
+ """
1818
+ # Use existing get_rc_files() method which returns sorted list
1819
+ rc_files = self.get_rc_files(version)
1820
+
1821
+ if not rc_files:
1822
+ # No RCs exist, this will be rc1
1823
+ return 1
1824
+
1825
+ # get_rc_files() returns sorted list, so last file has highest number
1826
+ # Extract RC number from last filename (e.g., "1.3.5-rc3.txt" → 3)
1827
+ last_rc_file = rc_files[-1].name
1828
+
1829
+ # Extract number after "-rc" (e.g., "1.3.5-rc3.txt" → "3")
1830
+ match = re.search(r'-rc(\d+)\.txt', last_rc_file)
1831
+ if match:
1832
+ last_rc_num = int(match.group(1))
1833
+ return last_rc_num + 1
1834
+
1835
+ # Fallback (shouldn't happen with valid RC files)
1836
+ return len(rc_files) + 1
1837
+
1838
+
1839
+ def _merge_archived_patches_to_ho_prod(self, version: str, stage_file: str) -> List[str]:
1840
+ """
1841
+ Merge all archived patch branches code into ho-prod.
1842
+
1843
+ THIS IS WHERE CODE ENTERS HO-PROD. During add-to-release, patches
1844
+ are archived to ho-release/X.Y.Z/patch-id but code stays separate.
1845
+ At promote_to, all archived patches are merged into ho-prod.
1846
+
1847
+ Algorithm:
1848
+ 1. Read patch list from stage file (e.g., releases/1.3.5-stage.txt)
1849
+ 2. For each patch in list:
1850
+ - Check if archived branch exists: ho-release/{version}/{patch_id}
1851
+ - If exists: git merge ho-release/{version}/{patch_id}
1852
+ - Handle merge conflicts (abort and raise error)
1853
+ 3. Return list of merged patches
1854
+
1855
+ Args:
1856
+ version: Version string (e.g., "1.3.5")
1857
+ stage_file: Stage filename (e.g., "1.3.5-stage.txt")
1858
+
1859
+ Returns:
1860
+ List of merged patch IDs
1861
+
1862
+ Raises:
1863
+ ReleaseManagerError: If merge conflicts occur or branch not found
1864
+
1865
+ Examples:
1866
+ # Successful merge
1867
+ releases/1.3.5-stage.txt contains: ["456-user-auth", "789-security"]
1868
+ ho-release/1.3.5/456-user-auth exists
1869
+ ho-release/1.3.5/789-security exists
1870
+
1871
+ patches = mgr._merge_archived_patches_to_ho_prod("1.3.5", "1.3.5-stage.txt")
1872
+ # → ["456-user-auth", "789-security"]
1873
+ # → Both branches merged into ho-prod
1874
+ # → Code now in ho-prod
1875
+
1876
+ # Merge conflict
1877
+ Patch code conflicts with existing ho-prod code
1878
+ patches = mgr._merge_archived_patches_to_ho_prod("1.3.5", "1.3.5-stage.txt")
1879
+ # → Raises: "Merge conflict with patch 456-user-auth, resolve manually"
1880
+
1881
+ # Missing archived branch
1882
+ releases/1.3.5-stage.txt contains: ["456-user-auth"]
1883
+ ho-release/1.3.5/456-user-auth does NOT exist
1884
+
1885
+ patches = mgr._merge_archived_patches_to_ho_prod("1.3.5", "1.3.5-stage.txt")
1886
+ # → Raises: "Archived branch not found: ho-release/1.3.5/456-user-auth"
1887
+
1888
+ Note:
1889
+ After this operation, ho-prod contains both metadata (releases/*.txt)
1890
+ and code (merged from ho-release/X.Y.Z/*). This is the key difference
1891
+ between stage (metadata only) and RC (metadata + code).
1892
+ """
1893
+ # Read patch list from stage file using existing method
1894
+ patch_ids = self.read_release_patches(stage_file)
1895
+
1896
+ if not patch_ids:
1897
+ # Empty stage file, no patches to merge
1898
+ return []
1899
+
1900
+ merged_patches = []
1901
+
1902
+ for patch_id in patch_ids:
1903
+ # Construct archived branch name
1904
+ archived_branch = f"ho-release/{version}/{patch_id}"
1905
+
1906
+ # Check if archived branch exists
1907
+ if not self._repo.hgit.branch_exists(archived_branch):
1908
+ raise ReleaseManagerError(
1909
+ f"Archived branch not found: {archived_branch}. "
1910
+ f"Patch {patch_id} was not properly archived during add-to-release."
1911
+ )
1912
+
1913
+ # Merge archived branch into ho-prod with no-ff
1914
+ try:
1915
+ self._repo.hgit.merge(
1916
+ archived_branch,
1917
+ no_ff=True,
1918
+ m=f"Integrate patch {patch_id}")
1919
+
1920
+ merged_patches.append(patch_id)
1921
+
1922
+ except GitCommandError as e:
1923
+ raise ReleaseManagerError(
1924
+ f"Merge conflict with patch {patch_id} from {archived_branch}. "
1925
+ f"Resolve conflicts manually and retry. Git error: {e}"
1926
+ )
1927
+
1928
+ return merged_patches
1929
+
1930
+
1931
+ def _cleanup_patch_branches(self, version: str, stage_file: str) -> List[str]:
1932
+ """
1933
+ Delete all patch branches listed in stage file.
1934
+
1935
+ Reads patch list from stage file and deletes both local and remote
1936
+ branches. This is automatic cleanup at promote_to to maintain
1937
+ clean repository state. Branches are ho-patch/* format.
1938
+
1939
+ Algorithm:
1940
+ 1. Read patch list from stage file
1941
+ 2. For each patch:
1942
+ - Check if ho-patch/{patch_id} exists locally
1943
+ - If exists: git branch -D ho-patch/{patch_id}
1944
+ - Check if exists on remote
1945
+ - If exists: git push origin --delete ho-patch/{patch_id}
1946
+ 3. Return list of deleted branches
1947
+
1948
+ Args:
1949
+ version: Version string (e.g., "1.3.5")
1950
+ stage_file: Stage filename (e.g., "1.3.5-stage.txt")
1951
+
1952
+ Returns:
1953
+ List of deleted branch names (e.g., ["ho-patch/456-user-auth", ...])
1954
+
1955
+ Raises:
1956
+ ReleaseManagerError: If branch deletion fails (e.g., uncommitted changes)
1957
+
1958
+ Examples:
1959
+ # Successful cleanup
1960
+ releases/1.3.5-stage.txt contains: ["456-user-auth", "789-security"]
1961
+ ho-patch/456-user-auth exists locally and remotely
1962
+ ho-patch/789-security exists locally and remotely
1963
+
1964
+ deleted = mgr._cleanup_patch_branches("1.3.5", "1.3.5-stage.txt")
1965
+ # → ["ho-patch/456-user-auth", "ho-patch/789-security"]
1966
+ # → Both branches deleted locally and remotely
1967
+
1968
+ # Branch already deleted
1969
+ releases/1.3.5-stage.txt contains: ["456-user-auth"]
1970
+ ho-patch/456-user-auth does NOT exist
1971
+
1972
+ deleted = mgr._cleanup_patch_branches("1.3.5", "1.3.5-stage.txt")
1973
+ # → [] (nothing to delete, no error)
1974
+
1975
+ # Branch with uncommitted changes (should not happen)
1976
+ ho-patch/456-user-auth has uncommitted changes
1977
+
1978
+ deleted = mgr._cleanup_patch_branches("1.3.5", "1.3.5-stage.txt")
1979
+ # → Raises: "Cannot delete ho-patch/456-user-auth: uncommitted changes"
1980
+
1981
+ Note:
1982
+ This is called AFTER merging archived branches to ho-prod, so the
1983
+ code is preserved in ho-prod even though branches are deleted.
1984
+ """
1985
+ patch_ids = self.read_release_patches(stage_file)
1986
+
1987
+ if not patch_ids:
1988
+ # Empty stage file, no branches to cleanup
1989
+ return []
1990
+
1991
+ deleted_branches = []
1992
+
1993
+ for patch_id in patch_ids:
1994
+ # Construct branch name
1995
+ branch_name = f"ho-patch/{patch_id}"
1996
+
1997
+ # Delete local branch (force delete with -D)
1998
+ try:
1999
+ self._repo.hgit.delete_branch(branch_name, force=True)
2000
+ except GitCommandError as e:
2001
+ # Best effort: continue even if deletion fails
2002
+ # (branch might already be deleted)
2003
+ pass
2004
+
2005
+ # Delete remote branch
2006
+ try:
2007
+ self._repo.hgit.delete_remote_branch(branch_name)
2008
+ except GitCommandError as e:
2009
+ # Best effort: continue even if deletion fails
2010
+ # (branch might already be deleted from remote)
2011
+ pass
2012
+
2013
+ # Add to deleted list (best effort reporting)
2014
+ deleted_branches.append(branch_name)
2015
+
2016
+ return deleted_branches
2017
+
2018
+ def _ensure_patch_branch_synced(self, patch_id: str) -> dict:
2019
+ """
2020
+ Ensure patch branch is synced with ho-prod before integration.
2021
+
2022
+ Automatically syncs patch branch by merging ho-prod INTO the patch branch.
2023
+ This ensures the patch branch has all latest changes from ho-prod before
2024
+ being integrated back into the release.
2025
+
2026
+ Direction: ho-prod → ho-patch/{patch_id}
2027
+ (update patch branch with latest production changes)
2028
+
2029
+ Simple merge strategy: ho-prod is merged INTO the patch branch using
2030
+ standard git merge. No fast-forward or rebase needed since full commit
2031
+ history is preserved during promote_to (no squash).
2032
+
2033
+ Sync Strategy:
2034
+ 1. Check if already synced → return immediately
2035
+ 2. Merge ho-prod into patch branch (standard merge)
2036
+ 3. If merge conflicts, block for manual resolution
2037
+
2038
+ This simple approach is appropriate because:
2039
+ - Full history is preserved at promote_to (no squash)
2040
+ - Merge commits in patch branches are acceptable
2041
+ - Individual commit history matters for traceability
2042
+
2043
+ Args:
2044
+ patch_id: Patch identifier (e.g., "456-user-auth")
2045
+
2046
+ Returns:
2047
+ dict: Sync result with keys:
2048
+ - 'strategy': Strategy used for sync
2049
+ * "already-synced": No action needed
2050
+ * "fast-forward": Clean fast-forward merge
2051
+ * "rebase": Linear history via rebase
2052
+ * "merge": Safe merge with merge commit
2053
+ - 'branch_name': Full branch name (e.g., "ho-patch/456-user-auth")
2054
+
2055
+ Raises:
2056
+ ReleaseManagerError: If automatic sync fails due to conflicts
2057
+ requiring manual resolution. Error message includes specific
2058
+ instructions for manual conflict resolution.
2059
+
2060
+ Examples:
2061
+ # Already synced
2062
+ result = self._ensure_patch_branch_synced("456-user-auth")
2063
+ # Returns: {'strategy': 'already-synced', 'branch_name': 'ho-patch/456-user-auth'}
2064
+
2065
+ # Behind - fast-forward successful
2066
+ result = self._ensure_patch_branch_synced("789-security")
2067
+ # Returns: {'strategy': 'fast-forward', 'branch_name': 'ho-patch/789-security'}
2068
+
2069
+ # Diverged - rebase successful
2070
+ result = self._ensure_patch_branch_synced("234-reports")
2071
+ # Returns: {'strategy': 'rebase', 'branch_name': 'ho-patch/234-reports'}
2072
+
2073
+ # Conflicts require manual resolution
2074
+ try:
2075
+ result = self._ensure_patch_branch_synced("999-bugfix")
2076
+ except ReleaseManagerError as e:
2077
+ # Error with manual resolution instructions
2078
+ pass
2079
+
2080
+ Side Effects:
2081
+ - Checks out patch branch temporarily
2082
+ - May create commits (merge) or rewrite history (rebase)
2083
+ - Pushes changes to remote (may require force push for rebase)
2084
+ - Returns to original branch after sync
2085
+
2086
+ Notes:
2087
+ - Fast-forward is preferred (cleanest, no extra commits)
2088
+ - Rebase is acceptable for ephemeral ho-patch/* branches
2089
+ - Merge is fallback when rebase has conflicts
2090
+ - Manual resolution required only for unresolvable conflicts
2091
+ - Non-blocking: continues workflow after successful sync
2092
+ """
2093
+ branch_name = f"ho-patch/{patch_id}"
2094
+
2095
+ # 1. Check if already synced
2096
+ is_synced, status = self._repo.hgit.is_branch_synced(branch_name)
2097
+
2098
+ if is_synced:
2099
+ return {
2100
+ 'strategy': 'already-synced',
2101
+ 'branch_name': branch_name
2102
+ }
2103
+
2104
+ # 2. Save current branch to return to later
2105
+ current_branch = self._repo.hgit.branch
2106
+
2107
+ try:
2108
+ # 3. Checkout patch branch
2109
+ self._repo.hgit.checkout(branch_name)
2110
+
2111
+ # 4. Merge ho-prod into patch branch (standard merge)
2112
+ try:
2113
+ self._repo.hgit.merge("ho-prod")
2114
+
2115
+ # 5. Push changes to remote
2116
+ self._repo.hgit.push()
2117
+
2118
+ # Success - return merge strategy
2119
+ return {
2120
+ 'strategy': 'merge',
2121
+ 'branch_name': branch_name
2122
+ }
2123
+
2124
+ except GitCommandError as e:
2125
+ # Merge conflicts - manual resolution required
2126
+ raise ReleaseManagerError(
2127
+ f"Branch {branch_name} has conflicts with ho-prod.\n"
2128
+ f"Manual resolution required:\n\n"
2129
+ f" git checkout {branch_name}\n"
2130
+ f" git merge ho-prod\n"
2131
+ f" # Resolve conflicts in your editor\n"
2132
+ f" git add .\n"
2133
+ f" git commit\n"
2134
+ f" git push\n\n"
2135
+ f"Then retry: half_orm dev add-to-release {patch_id}\n\n"
2136
+ f"Git error: {e}"
2137
+ )
2138
+
2139
+ finally:
2140
+ # 6. Always return to original branch (best effort)
2141
+ try:
2142
+ self._repo.hgit.checkout(current_branch)
2143
+ except Exception:
2144
+ # Best effort - don't fail if checkout back fails
2145
+ pass
2146
+
2147
+ def update_production(self) -> dict:
2148
+ """
2149
+ Fetch tags and list available releases for production upgrade (read-only).
2150
+
2151
+ Equivalent to 'apt update' - synchronizes with origin and shows available
2152
+ releases but makes NO modifications to database or repository.
2153
+
2154
+ Workflow:
2155
+ 1. Fetch tags from origin (git fetch --tags)
2156
+ 2. Read current production version from database (hop_last_release)
2157
+ 3. List available release tags (v1.3.6, v1.3.6-rc1, v1.4.0)
2158
+ 4. Calculate sequential upgrade path
2159
+ 5. Return structured results for CLI display
2160
+
2161
+ Returns:
2162
+ dict: Update information with structure:
2163
+ {
2164
+ 'current_version': str, # e.g., "1.3.5"
2165
+ 'available_releases': List[dict], # List of available tags
2166
+ 'upgrade_path': List[str], # Sequential path
2167
+ 'has_updates': bool # True if updates available
2168
+ }
2169
+
2170
+ Each item in 'available_releases':
2171
+ {
2172
+ 'tag': str, # e.g., "v1.3.6"
2173
+ 'version': str, # e.g., "1.3.6"
2174
+ 'type': str, # 'production', 'rc', or 'hotfix'
2175
+ 'patches': List[str] # Patch IDs in release
2176
+ }
2177
+
2178
+ Raises:
2179
+ ReleaseManagerError: If cannot fetch tags or read database version
2180
+
2181
+ Examples:
2182
+ # List available production releases
2183
+ result = mgr.update_production()
2184
+ print(f"Current: {result['current_version']}")
2185
+ for rel in result['available_releases']:
2186
+ print(f" → {rel['version']} ({len(rel['patches'])} patches)")
2187
+
2188
+ # Include RC releases
2189
+ result = mgr.update_production()
2190
+ # → Shows v1.3.6-rc1, v1.3.6, v1.4.0
2191
+ """
2192
+ allow_rc = self._repo.allow_rc
2193
+
2194
+ # 1. Get available release tags from origin
2195
+ available_tags = self._get_available_release_tags(allow_rc=allow_rc)
2196
+
2197
+ # 2. Read current production version from database
2198
+ try:
2199
+ current_version = self._repo.database.last_release_s
2200
+ except Exception as e:
2201
+ raise ReleaseManagerError(
2202
+ f"Cannot read current production version from database: {e}"
2203
+ )
2204
+
2205
+ # 3. Build list of available releases with details
2206
+ available_releases = []
2207
+
2208
+ for tag in available_tags:
2209
+ # Extract version from tag (remove 'v' prefix)
2210
+ version = tag[1:]
2211
+
2212
+ # Determine release type
2213
+ if '-rc' in version:
2214
+ release_type = 'rc'
2215
+ elif '-hotfix' in version:
2216
+ release_type = 'hotfix'
2217
+ else:
2218
+ release_type = 'production'
2219
+
2220
+ # Extract base version for file lookup (remove suffix)
2221
+ base_version = version.split('-')[0]
2222
+
2223
+ # Read patches from release file
2224
+ release_file = self._releases_dir / f"{version}.txt"
2225
+ patches = []
2226
+
2227
+ if release_file.exists():
2228
+ content = release_file.read_text().strip()
2229
+ if content:
2230
+ patches = [line.strip() for line in content.split('\n') if line.strip()]
2231
+
2232
+ # Only include releases newer than current version
2233
+ if self._version_is_newer(version, current_version):
2234
+ available_releases.append({
2235
+ 'tag': tag,
2236
+ 'version': version,
2237
+ 'type': release_type,
2238
+ 'patches': patches
2239
+ })
2240
+
2241
+ # 4. Calculate upgrade path (implemented in Artefact 3B)
2242
+ upgrade_path = []
2243
+ if available_releases:
2244
+ # Extract production versions only for upgrade path
2245
+ production_versions = [
2246
+ rel['version'] for rel in available_releases
2247
+ if rel['type'] == 'production'
2248
+ ]
2249
+
2250
+ if production_versions:
2251
+ # Use last production version as target
2252
+ target_version = production_versions[-1]
2253
+ upgrade_path = self._calculate_upgrade_path(current_version, target_version)
2254
+
2255
+ # 5. Return results
2256
+ return {
2257
+ 'current_version': current_version,
2258
+ 'available_releases': available_releases,
2259
+ 'upgrade_path': upgrade_path,
2260
+ 'has_updates': len(available_releases) > 0
2261
+ }
2262
+
2263
+ def _get_available_release_tags(self, allow_rc: bool = False) -> List[str]:
2264
+ """
2265
+ Get available release tags from Git repository.
2266
+
2267
+ Fetches tags from origin and filters for release tags (v*.*.*).
2268
+ Excludes RC tags unless allow_rc=True.
2269
+
2270
+ Args:
2271
+ allow_rc: If True, include RC tags (v1.3.6-rc1)
2272
+
2273
+ Returns:
2274
+ List[str]: Sorted list of tag names (e.g., ["v1.3.6", "v1.4.0"])
2275
+
2276
+ Raises:
2277
+ ReleaseManagerError: If fetch fails
2278
+
2279
+ Examples:
2280
+ # Production only
2281
+ tags = mgr._get_available_release_tags()
2282
+ # → ["v1.3.6", "v1.4.0"]
2283
+
2284
+ # Include RC
2285
+ tags = mgr._get_available_release_tags(allow_rc=True)
2286
+ # → ["v1.3.6-rc1", "v1.3.6", "v1.4.0"]
2287
+ """
2288
+ try:
2289
+ # Fetch tags from origin
2290
+ self._repo.hgit.fetch_tags()
2291
+ except Exception as e:
2292
+ raise ReleaseManagerError(f"Failed to fetch tags from origin: {e}")
2293
+
2294
+ # Get all tags from repository
2295
+ try:
2296
+ all_tags = self._repo.hgit._HGit__git_repo.tags
2297
+ except Exception as e:
2298
+ raise ReleaseManagerError(f"Failed to read tags from repository: {e}")
2299
+
2300
+ # Filter for release tags (v*.*.*) with optional -rc or -hotfix suffix
2301
+ release_pattern = re.compile(r'^v\d+\.\d+\.\d+(-rc\d+|-hotfix\d+)?$')
2302
+ release_tags = []
2303
+
2304
+ for tag in all_tags:
2305
+ tag_name = tag.name
2306
+ if release_pattern.match(tag_name):
2307
+ # Filter RC tags unless explicitly allowed
2308
+ if '-rc' in tag_name and not allow_rc:
2309
+ continue
2310
+ release_tags.append(tag_name)
2311
+
2312
+ # Sort tags by version (semantic versioning)
2313
+ def version_key(tag_name):
2314
+ """Extract sortable version tuple from tag name."""
2315
+ # Remove 'v' prefix
2316
+ version_str = tag_name[1:]
2317
+
2318
+ # Split version and suffix
2319
+ if '-rc' in version_str:
2320
+ base_ver, rc_suffix = version_str.split('-rc')
2321
+ rc_num = int(rc_suffix)
2322
+ suffix_weight = (1, rc_num) # RC comes before production
2323
+ elif '-hotfix' in version_str:
2324
+ base_ver, hotfix_suffix = version_str.split('-hotfix')
2325
+ hotfix_num = int(hotfix_suffix)
2326
+ suffix_weight = (2, hotfix_num) # Hotfix comes after production
2327
+ else:
2328
+ base_ver = version_str
2329
+ suffix_weight = (1.5, 0) # Production between RC and hotfix
2330
+
2331
+ # Parse base version
2332
+ major, minor, patch = map(int, base_ver.split('.'))
2333
+
2334
+ return (major, minor, patch, suffix_weight)
2335
+
2336
+ release_tags.sort(key=version_key)
2337
+
2338
+ return release_tags
2339
+
2340
+ def _calculate_upgrade_path(
2341
+ self,
2342
+ current: str,
2343
+ target: str
2344
+ ) -> List[str]:
2345
+ """
2346
+ Calculate sequential upgrade path between two versions.
2347
+
2348
+ Determines all intermediate versions needed to upgrade from
2349
+ current to target version. Versions must be applied sequentially.
2350
+
2351
+ Args:
2352
+ current: Current production version (e.g., "1.3.5")
2353
+ target: Target version (e.g., "1.4.0")
2354
+
2355
+ Returns:
2356
+ List[str]: Ordered list of versions to apply
2357
+
2358
+ Examples:
2359
+ # Direct upgrade
2360
+ path = mgr._calculate_upgrade_path("1.3.5", "1.3.6")
2361
+ # → ["1.3.6"]
2362
+
2363
+ # Multi-step upgrade
2364
+ path = mgr._calculate_upgrade_path("1.3.5", "1.4.0")
2365
+ # → ["1.3.6", "1.4.0"]
2366
+
2367
+ # No upgrades needed
2368
+ path = mgr._calculate_upgrade_path("1.4.0", "1.4.0")
2369
+ # → []
2370
+ """
2371
+ # Parse versions
2372
+ current_version = self.parse_version_from_filename(f"{current}.txt")
2373
+ target_version = self.parse_version_from_filename(f"{target}.txt")
2374
+
2375
+ # If same version, no upgrade needed
2376
+ if current == target:
2377
+ return []
2378
+
2379
+ # Get all available release tags (production only)
2380
+ available_tags = self._get_available_release_tags(allow_rc=False)
2381
+
2382
+ # Extract versions from tags and parse them
2383
+ available_versions = []
2384
+ for tag in available_tags:
2385
+ # Remove 'v' prefix: v1.3.6 → 1.3.6
2386
+ version_str = tag[1:] if tag.startswith('v') else tag
2387
+
2388
+ # Skip if not a valid production version format
2389
+ if not re.match(r'^\d+\.\d+\.\d+$', version_str):
2390
+ continue
2391
+
2392
+ try:
2393
+ version = self.parse_version_from_filename(f"{version_str}.txt")
2394
+ available_versions.append((version_str, version))
2395
+ except Exception:
2396
+ continue
2397
+
2398
+ # Sort versions
2399
+ available_versions.sort(key=lambda x: (x[1].major, x[1].minor, x[1].patch))
2400
+
2401
+ # Build sequential path from current to target
2402
+ path = []
2403
+ for version_str, version in available_versions:
2404
+ # Skip versions <= current
2405
+ if (version.major, version.minor, version.patch) <= \
2406
+ (current_version.major, current_version.minor, current_version.patch):
2407
+ continue
2408
+
2409
+ # Add versions <= target
2410
+ if (version.major, version.minor, version.patch) <= \
2411
+ (target_version.major, target_version.minor, target_version.patch):
2412
+ path.append(version_str)
2413
+
2414
+ return path
2415
+
2416
+ def _version_is_newer(self, version1: str, version2: str) -> bool:
2417
+ """
2418
+ Compare two version strings to check if version1 is newer than version2.
2419
+
2420
+ Args:
2421
+ version1: First version (e.g., "1.3.6", "1.3.6-rc1")
2422
+ version2: Second version (e.g., "1.3.5")
2423
+
2424
+ Returns:
2425
+ bool: True if version1 > version2
2426
+
2427
+ Examples:
2428
+ _version_is_newer("1.3.6", "1.3.5") # → True
2429
+ _version_is_newer("1.3.5", "1.3.6") # → False
2430
+ _version_is_newer("1.3.6-rc1", "1.3.5") # → True
2431
+ """
2432
+ # Extract base versions (remove suffix)
2433
+ base1 = version1.split('-')[0]
2434
+ base2 = version2.split('-')[0]
2435
+
2436
+ # Parse versions
2437
+ parts1 = tuple(map(int, base1.split('.')))
2438
+ parts2 = tuple(map(int, base2.split('.')))
2439
+
2440
+ return parts1 > parts2
2441
+
2442
+ def upgrade_production(
2443
+ self,
2444
+ to_version: Optional[str] = None,
2445
+ dry_run: bool = False,
2446
+ force_backup: bool = False,
2447
+ skip_backup: bool = False
2448
+ ) -> dict:
2449
+ """
2450
+ Upgrade production database to target version.
2451
+
2452
+ Applies releases sequentially to production database. This is the
2453
+ production-safe upgrade workflow that NEVER destroys the database,
2454
+ working incrementally on existing data.
2455
+
2456
+ CRITICAL: This method works on EXISTING production database.
2457
+ It does NOT use restore_database_from_schema() which would destroy data.
2458
+
2459
+ Workflow:
2460
+ 1. CREATE BACKUP (first action, before any validation)
2461
+ 2. Validate production environment (ho-prod branch, clean repo)
2462
+ 3. Fetch available releases via update_production()
2463
+ 4. Calculate upgrade path (all or to specific version)
2464
+ 5. Apply each release sequentially on existing database
2465
+ 6. Update database version after each release
2466
+
2467
+ Args:
2468
+ to_version: Stop at specific version (e.g., "1.3.6")
2469
+ If None, apply all available releases
2470
+ dry_run: Simulate without modifying database or creating backup
2471
+ force_backup: Overwrite existing backup file without confirmation
2472
+ skip_backup: Skip backup creation (DANGEROUS - for testing only)
2473
+
2474
+ Returns:
2475
+ dict: Upgrade result with detailed information
2476
+
2477
+ Structure:
2478
+ 'status': 'success' or 'dry_run'
2479
+ 'dry_run': bool
2480
+ 'backup_created': Path or None (if dry_run or skip_backup)
2481
+ 'current_version': str (version before upgrade)
2482
+ 'target_version': str or None (explicit target or None for "all")
2483
+ 'releases_applied': List[str] (versions applied)
2484
+ 'patches_applied': Dict[str, List[str]] (patches per release)
2485
+ 'final_version': str (version after upgrade)
2486
+
2487
+ Raises:
2488
+ ReleaseManagerError: For validation failures or application errors
2489
+
2490
+ Examples:
2491
+ # Upgrade to latest (all available releases)
2492
+ result = mgr.upgrade_production()
2493
+ # Current: 1.3.5
2494
+ # Applies: 1.3.6 → 1.3.7 → 1.4.0
2495
+ # Result: {
2496
+ # 'status': 'success',
2497
+ # 'backup_created': Path('backups/1.3.5.sql'),
2498
+ # 'current_version': '1.3.5',
2499
+ # 'target_version': None,
2500
+ # 'releases_applied': ['1.3.6', '1.3.7', '1.4.0'],
2501
+ # 'patches_applied': {
2502
+ # '1.3.6': ['456-auth', '789-security'],
2503
+ # '1.3.7': ['999-bugfix'],
2504
+ # '1.4.0': ['111-feature']
2505
+ # },
2506
+ # 'final_version': '1.4.0'
2507
+ # }
2508
+
2509
+ # Upgrade to specific version
2510
+ result = mgr.upgrade_production(to_version="1.3.7")
2511
+ # Current: 1.3.5
2512
+ # Applies: 1.3.6 → 1.3.7 (stops here)
2513
+ # Result: {
2514
+ # 'status': 'success',
2515
+ # 'target_version': '1.3.7',
2516
+ # 'releases_applied': ['1.3.6', '1.3.7'],
2517
+ # 'final_version': '1.3.7'
2518
+ # }
2519
+
2520
+ # Dry run (no changes)
2521
+ result = mgr.upgrade_production(dry_run=True)
2522
+ # Result: {
2523
+ # 'status': 'dry_run',
2524
+ # 'dry_run': True,
2525
+ # 'backup_would_be_created': 'backups/1.3.5.sql',
2526
+ # 'releases_would_apply': ['1.3.6', '1.3.7'],
2527
+ # 'patches_would_apply': {...}
2528
+ # }
2529
+
2530
+ # Already up to date
2531
+ result = mgr.upgrade_production()
2532
+ # Result: {
2533
+ # 'status': 'success',
2534
+ # 'current_version': '1.4.0',
2535
+ # 'releases_applied': [],
2536
+ # 'message': 'Production already at latest version'
2537
+ # }
2538
+ """
2539
+ from half_orm_dev.release_manager import ReleaseManagerError
2540
+
2541
+ # Get current version
2542
+ current_version = self._repo.database.last_release_s
2543
+
2544
+ # === 1. BACKUP FIRST (unless dry_run or skip_backup) ===
2545
+ backup_path = None
2546
+ if not dry_run and not skip_backup:
2547
+ backup_path = self._create_production_backup(
2548
+ current_version,
2549
+ force=force_backup
2550
+ )
2551
+
2552
+ # === 2. Validate environment ===
2553
+ self._validate_production_upgrade()
2554
+
2555
+ # === 3. Get available releases ===
2556
+ update_info = self.update_production()
2557
+
2558
+ # Check if already up to date
2559
+ if not update_info['has_updates']:
2560
+ return {
2561
+ 'status': 'success',
2562
+ 'dry_run': False,
2563
+ 'backup_created': backup_path,
2564
+ 'current_version': current_version,
2565
+ 'target_version': to_version,
2566
+ 'releases_applied': [],
2567
+ 'patches_applied': {},
2568
+ 'final_version': current_version,
2569
+ 'message': 'Production already at latest version'
2570
+ }
2571
+
2572
+ # === 4. Calculate upgrade path ===
2573
+ if to_version:
2574
+ # Upgrade to specific version
2575
+ full_path = update_info['upgrade_path']
2576
+
2577
+ # Validate target version exists
2578
+ if to_version not in full_path:
2579
+ raise ReleaseManagerError(
2580
+ f"Target version {to_version} not in upgrade path. "
2581
+ f"Available versions: {', '.join(full_path)}"
2582
+ )
2583
+
2584
+ # Truncate path to target
2585
+ upgrade_path = []
2586
+ for version in full_path:
2587
+ upgrade_path.append(version)
2588
+ if version == to_version:
2589
+ break
2590
+ else:
2591
+ # Upgrade to latest (all releases)
2592
+ upgrade_path = update_info['upgrade_path']
2593
+
2594
+ # === DRY RUN - Stop here and return simulation ===
2595
+ if dry_run:
2596
+ # Build patches_would_apply dict
2597
+ patches_would_apply = {}
2598
+ for version in upgrade_path:
2599
+ patches = self.read_release_patches(f"{version}.txt")
2600
+ patches_would_apply[version] = patches
2601
+
2602
+ return {
2603
+ 'status': 'dry_run',
2604
+ 'dry_run': True,
2605
+ 'backup_would_be_created': f'backups/{current_version}.sql',
2606
+ 'current_version': current_version,
2607
+ 'target_version': to_version,
2608
+ 'releases_would_apply': upgrade_path,
2609
+ 'patches_would_apply': patches_would_apply,
2610
+ 'final_version': upgrade_path[-1] if upgrade_path else current_version
2611
+ }
2612
+
2613
+ # === 5. Apply releases sequentially ===
2614
+ patches_applied = {}
2615
+
2616
+ try:
2617
+ for version in upgrade_path:
2618
+ # Apply release and collect patches
2619
+ applied_patches = self._apply_release_to_production(version)
2620
+ patches_applied[version] = applied_patches
2621
+
2622
+ except Exception as e:
2623
+ # On error, provide rollback instructions
2624
+ raise ReleaseManagerError(
2625
+ f"Failed to apply release {version}: {e}\n\n"
2626
+ f"ROLLBACK INSTRUCTIONS:\n"
2627
+ f"1. Restore database: psql -d {self._repo.database.name} -f {backup_path}\n"
2628
+ f"2. Verify restoration: SELECT * FROM half_orm_meta.hop_release ORDER BY id DESC LIMIT 1;\n"
2629
+ f"3. Fix the failing patch and retry upgrade"
2630
+ ) from e
2631
+
2632
+ # === 6. Build success result ===
2633
+ final_version = upgrade_path[-1] if upgrade_path else current_version
2634
+
2635
+ return {
2636
+ 'status': 'success',
2637
+ 'dry_run': False,
2638
+ 'backup_created': backup_path,
2639
+ 'current_version': current_version,
2640
+ 'target_version': to_version,
2641
+ 'releases_applied': upgrade_path,
2642
+ 'patches_applied': patches_applied,
2643
+ 'final_version': final_version
2644
+ }
2645
+
2646
+
2647
+ def _create_production_backup(
2648
+ self,
2649
+ current_version: str,
2650
+ force: bool = False
2651
+ ) -> Path:
2652
+ """
2653
+ Create production database backup before upgrade.
2654
+
2655
+ Creates backups/{version}.sql using pg_dump with full database dump
2656
+ (schema + data + metadata). This is the rollback point if upgrade fails.
2657
+
2658
+ Args:
2659
+ current_version: Current database version (e.g., "1.3.5")
2660
+ force: Overwrite existing backup without confirmation
2661
+
2662
+ Returns:
2663
+ Path: Backup file path (e.g., Path("backups/1.3.5.sql"))
2664
+
2665
+ Raises:
2666
+ ReleaseManagerError: If backup creation fails or user declines overwrite
2667
+
2668
+ Examples:
2669
+ # Create new backup
2670
+ path = mgr._create_production_backup("1.3.5")
2671
+ # → Creates backups/1.3.5.sql
2672
+ # → Returns Path('backups/1.3.5.sql')
2673
+
2674
+ # Backup exists, user confirms overwrite
2675
+ path = mgr._create_production_backup("1.3.5", force=False)
2676
+ # → Prompt: "Backup exists. Overwrite? [y/N]"
2677
+ # → User enters 'y'
2678
+ # → Overwrites backups/1.3.5.sql
2679
+
2680
+ # Backup exists, force=True
2681
+ path = mgr._create_production_backup("1.3.5", force=True)
2682
+ # → Overwrites without prompt
2683
+
2684
+ # Backup exists, user declines
2685
+ path = mgr._create_production_backup("1.3.5", force=False)
2686
+ # → User enters 'n'
2687
+ # → Raises: "Backup exists and user declined overwrite"
2688
+ """
2689
+ from half_orm_dev.release_manager import ReleaseManagerError
2690
+
2691
+ # Create backups directory if doesn't exist
2692
+ backups_dir = Path(self._repo.base_dir) / "backups"
2693
+ backups_dir.mkdir(exist_ok=True)
2694
+
2695
+ # Build backup filename
2696
+ backup_file = backups_dir / f"{current_version}.sql"
2697
+
2698
+ # Check if backup already exists
2699
+ if backup_file.exists() and not force:
2700
+ # Prompt user for confirmation
2701
+ response = input(
2702
+ f"Backup {backup_file} already exists. "
2703
+ f"Overwrite? [y/N]: "
2704
+ ).strip().lower()
2705
+
2706
+ if response != 'y':
2707
+ raise ReleaseManagerError(
2708
+ f"Backup {backup_file} already exists. "
2709
+ f"Use --force to overwrite or remove the file manually."
2710
+ )
2711
+
2712
+ # Create backup using pg_dump
2713
+ try:
2714
+ self._repo.database.execute_pg_command(
2715
+ 'pg_dump',
2716
+ '-f', str(backup_file),
2717
+ )
2718
+ except Exception as e:
2719
+ raise ReleaseManagerError(
2720
+ f"Failed to create backup {backup_file}: {e}"
2721
+ ) from e
2722
+
2723
+ return backup_file
2724
+
2725
+
2726
+ def _validate_production_upgrade(self) -> None:
2727
+ """
2728
+ Validate production environment before upgrade.
2729
+
2730
+ Checks:
2731
+ 1. Current branch is ho-prod (production branch)
2732
+ 2. Repository is clean (no uncommitted changes)
2733
+
2734
+ Raises:
2735
+ ReleaseManagerError: If validation fails
2736
+
2737
+ Examples:
2738
+ # Valid state
2739
+ # Branch: ho-prod
2740
+ # Status: clean
2741
+ mgr._validate_production_upgrade()
2742
+ # → Returns without error
2743
+
2744
+ # Wrong branch
2745
+ # Branch: ho-patch/456-test
2746
+ mgr._validate_production_upgrade()
2747
+ # → Raises: "Must be on ho-prod branch"
2748
+
2749
+ # Uncommitted changes
2750
+ # Branch: ho-prod
2751
+ # Status: modified files
2752
+ mgr._validate_production_upgrade()
2753
+ # → Raises: "Repository has uncommitted changes"
2754
+ """
2755
+ from half_orm_dev.release_manager import ReleaseManagerError
2756
+
2757
+ # Check branch
2758
+ if self._repo.hgit.branch != "ho-prod":
2759
+ raise ReleaseManagerError(
2760
+ f"Must be on ho-prod branch for production upgrade. "
2761
+ f"Current branch: {self._repo.hgit.branch}"
2762
+ )
2763
+
2764
+ # Check repo is clean
2765
+ if not self._repo.hgit.repos_is_clean():
2766
+ raise ReleaseManagerError(
2767
+ "Repository has uncommitted changes. "
2768
+ "Commit or stash changes before upgrading production."
2769
+ )
2770
+
2771
+
2772
+ def _apply_release_to_production(self, version: str) -> List[str]:
2773
+ """
2774
+ Apply single release to existing production database.
2775
+
2776
+ Reads patches from releases/{version}.txt and applies them sequentially
2777
+ to the existing database using PatchManager.apply_patch_files().
2778
+ Updates database version after successful application.
2779
+
2780
+ CRITICAL: Works on EXISTING database. Does NOT restore/recreate.
2781
+
2782
+ Args:
2783
+ version: Release version (e.g., "1.3.6")
2784
+
2785
+ Returns:
2786
+ List[str]: Patch IDs applied (e.g., ["456-auth", "789-security"])
2787
+
2788
+ Raises:
2789
+ ReleaseManagerError: If patch application fails
2790
+
2791
+ Examples:
2792
+ # Apply release with multiple patches
2793
+ # releases/1.3.6.txt contains: 456-auth, 789-security
2794
+ patches = mgr._apply_release_to_production("1.3.6")
2795
+ # → Applies 456-auth to existing DB
2796
+ # → Applies 789-security to existing DB
2797
+ # → Updates DB version to 1.3.6
2798
+ # → Returns ["456-auth", "789-security"]
2799
+
2800
+ # Apply release with no patches (empty release)
2801
+ # releases/1.3.6.txt is empty
2802
+ patches = mgr._apply_release_to_production("1.3.6")
2803
+ # → Updates DB version to 1.3.6
2804
+ # → Returns []
2805
+
2806
+ # Patch application fails
2807
+ # 789-security has SQL error
2808
+ patches = mgr._apply_release_to_production("1.3.6")
2809
+ # → Applies 456-auth successfully
2810
+ # → 789-security fails
2811
+ # → Raises exception with error details
2812
+ """
2813
+ from half_orm_dev.release_manager import ReleaseManagerError
2814
+
2815
+ # Read patches from release file
2816
+ release_file = f"{version}.txt"
2817
+ patches = self.read_release_patches(release_file)
2818
+
2819
+ # Apply each patch sequentially
2820
+ for patch_id in patches:
2821
+ try:
2822
+ self._repo.patch_manager.apply_patch_files(
2823
+ patch_id,
2824
+ self._repo.model
2825
+ )
2826
+ except Exception as e:
2827
+ raise ReleaseManagerError(
2828
+ f"Failed to apply patch {patch_id} from release {version}: {e}"
2829
+ ) from e
2830
+
2831
+ # Update database version
2832
+ version_parts = version.split('.')
2833
+ if len(version_parts) != 3:
2834
+ raise ReleaseManagerError(
2835
+ f"Invalid version format: {version}. Expected X.Y.Z"
2836
+ )
2837
+
2838
+ major, minor, patch = map(int, version_parts)
2839
+ self._repo.database.register_release(major, minor, patch)
2840
+
2841
+ return patches