half-orm-dev 0.17.3a8__tar.gz → 0.17.3a9__tar.gz

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 (67) hide show
  1. {half_orm_dev-0.17.3a8/half_orm_dev.egg-info → half_orm_dev-0.17.3a9}/PKG-INFO +1 -1
  2. half_orm_dev-0.17.3a9/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +204 -0
  3. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/patch_manager.py +264 -61
  4. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/release_file.py +32 -18
  5. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/release_manager.py +168 -36
  6. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/repo.py +124 -0
  7. half_orm_dev-0.17.3a9/half_orm_dev/version.txt +1 -0
  8. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9/half_orm_dev.egg-info}/PKG-INFO +1 -1
  9. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev.egg-info/SOURCES.txt +1 -0
  10. half_orm_dev-0.17.3a8/half_orm_dev/version.txt +0 -1
  11. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/AUTHORS +0 -0
  12. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/LICENSE +0 -0
  13. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/README.md +0 -0
  14. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/__init__.py +0 -0
  15. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/__init__.py +0 -0
  16. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/__init__.py +0 -0
  17. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/apply.py +0 -0
  18. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/check.py +0 -0
  19. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/clone.py +0 -0
  20. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/init.py +0 -0
  21. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/migrate.py +0 -0
  22. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/patch.py +0 -0
  23. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/release.py +0 -0
  24. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/restore.py +0 -0
  25. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/sync.py +0 -0
  26. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/todo.py +0 -0
  27. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/undo.py +0 -0
  28. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/update.py +0 -0
  29. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/commands/upgrade.py +0 -0
  30. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli/main.py +0 -0
  31. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/cli_extension.py +0 -0
  32. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/database.py +0 -0
  33. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/decorators.py +0 -0
  34. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/hgit.py +0 -0
  35. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/migration_manager.py +0 -0
  36. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  37. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  38. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/modules.py +0 -0
  39. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/patch_validator.py +0 -0
  40. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  41. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  42. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  43. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/patches/log +0 -0
  44. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  45. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/scripts/repair-metadata.py +0 -0
  46. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/.gitignore +0 -0
  47. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/MANIFEST.in +0 -0
  48. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/Pipfile +0 -0
  49. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/README +0 -0
  50. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/conftest_template +0 -0
  51. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  52. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  53. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/init_module_template +0 -0
  54. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/module_template_1 +0 -0
  55. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/module_template_2 +0 -0
  56. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/module_template_3 +0 -0
  57. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/pyproject.toml +0 -0
  58. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/relation_test +0 -0
  59. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/sql_adapter +0 -0
  60. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/templates/warning +0 -0
  61. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev/utils.py +0 -0
  62. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  63. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev.egg-info/requires.txt +0 -0
  64. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/half_orm_dev.egg-info/top_level.txt +0 -0
  65. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/pyproject.toml +0 -0
  66. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/setup.cfg +0 -0
  67. {half_orm_dev-0.17.3a8 → half_orm_dev-0.17.3a9}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 0.17.3a8
3
+ Version: 0.17.3a9
4
4
  Summary: half_orm development Framework.
5
5
  Author-email: Joël Maïzi <joel.maizi@collorg.org>
6
6
  License-Expression: GPL-3.0-or-later
@@ -0,0 +1,204 @@
1
+ """
2
+ Migration: Convert TOML patches to dict format with merge_commit.
3
+
4
+ This migration converts the old TOML format:
5
+ [patches]
6
+ "1-auth" = "staged"
7
+ "2-api" = "candidate"
8
+
9
+ To the new dict format:
10
+ [patches]
11
+ "1-auth" = { status = "staged", merge_commit = "abc123de" }
12
+ "2-api" = { status = "candidate" }
13
+
14
+ For staged patches, the merge_commit hash is retrieved from git history.
15
+ """
16
+
17
+ from pathlib import Path
18
+ import subprocess
19
+ import sys
20
+
21
+ try:
22
+ import tomli
23
+ except ImportError:
24
+ import tomllib as tomli
25
+
26
+ try:
27
+ import tomli_w
28
+ except ImportError:
29
+ raise ImportError(
30
+ "tomli_w is required for this migration. "
31
+ "Install it with: pip install tomli_w"
32
+ )
33
+
34
+
35
+ def get_description():
36
+ """Return migration description."""
37
+ return "Convert TOML patches to dict format with merge_commit"
38
+
39
+
40
+ def find_merge_commit(repo, patch_id: str, version: str) -> str:
41
+ """
42
+ Find the merge commit hash for a staged patch.
43
+
44
+ Searches git history for the commit that merged the patch branch
45
+ into the release branch.
46
+
47
+ Args:
48
+ repo: Repo instance
49
+ patch_id: Patch identifier (e.g., "456-user-auth")
50
+ version: Release version (e.g., "0.17.0")
51
+
52
+ Returns:
53
+ Commit hash (8 characters) or empty string if not found
54
+ """
55
+ release_branch = f"ho-release/{version}"
56
+
57
+ try:
58
+ # Search for merge commit message pattern
59
+ # Pattern: [HOP] Merge #PATCH_ID into %"VERSION"
60
+ result = subprocess.run(
61
+ ['git', 'log', '--all', '--grep', f'Merge #{patch_id}',
62
+ '--format=%H', '-n', '1'],
63
+ cwd=repo.base_dir,
64
+ capture_output=True,
65
+ text=True,
66
+ check=True
67
+ )
68
+
69
+ commit_hash = result.stdout.strip()
70
+ if commit_hash:
71
+ return commit_hash[:8]
72
+
73
+ # Fallback: search for the move to stage commit
74
+ # Pattern: [HOP] move patch #PATCH_ID from candidate to stage
75
+ result = subprocess.run(
76
+ ['git', 'log', '--all', '--grep', f'move patch #{patch_id}',
77
+ '--format=%H', '-n', '1'],
78
+ cwd=repo.base_dir,
79
+ capture_output=True,
80
+ text=True,
81
+ check=True
82
+ )
83
+
84
+ commit_hash = result.stdout.strip()
85
+ if commit_hash:
86
+ # Get the parent commit (the merge commit is the one before the move)
87
+ result = subprocess.run(
88
+ ['git', 'rev-parse', f'{commit_hash}^'],
89
+ cwd=repo.base_dir,
90
+ capture_output=True,
91
+ text=True,
92
+ check=True
93
+ )
94
+ parent_hash = result.stdout.strip()
95
+ if parent_hash:
96
+ return parent_hash[:8]
97
+
98
+ except subprocess.CalledProcessError:
99
+ pass
100
+
101
+ return ""
102
+
103
+
104
+ def migrate(repo):
105
+ """
106
+ Execute migration: Convert TOML patches to dict format.
107
+
108
+ For each X.Y.Z-patches.toml file:
109
+ 1. Read current content
110
+ 2. Check if already in dict format
111
+ 3. Convert to dict format:
112
+ - candidates: { status = "candidate" }
113
+ - staged: { status = "staged", merge_commit = "..." }
114
+ 4. Find merge_commit from git history for staged patches
115
+ 5. Write updated TOML file
116
+
117
+ Args:
118
+ repo: Repo instance
119
+ """
120
+ print("Migrating TOML patches to dict format with merge_commit...")
121
+
122
+ releases_dir = Path(repo.releases_dir)
123
+ if not releases_dir.exists():
124
+ print(" No releases directory found, skipping migration.")
125
+ return
126
+
127
+ # Find all TOML patches files
128
+ toml_files = list(releases_dir.glob("*-patches.toml"))
129
+
130
+ if not toml_files:
131
+ print(" No TOML patches files found, skipping migration.")
132
+ return
133
+
134
+ migrated_count = 0
135
+
136
+ for toml_file in toml_files:
137
+ # Extract version from filename
138
+ version = toml_file.stem.replace('-patches', '')
139
+
140
+ print(f" Processing {version}...")
141
+
142
+ try:
143
+ # Read current TOML content
144
+ with toml_file.open('rb') as f:
145
+ data = tomli.load(f)
146
+
147
+ patches = data.get("patches", {})
148
+
149
+ if not patches:
150
+ print(f" No patches in {version}, skipping")
151
+ continue
152
+
153
+ # Check if already in dict format
154
+ first_value = next(iter(patches.values()))
155
+ if isinstance(first_value, dict):
156
+ print(f" Already in dict format, skipping")
157
+ continue
158
+
159
+ # Convert to dict format
160
+ new_patches = {}
161
+ staged_without_commit = []
162
+
163
+ for patch_id, status in patches.items():
164
+ if status == "candidate":
165
+ new_patches[patch_id] = {"status": "candidate"}
166
+ elif status == "staged":
167
+ # Find merge commit from git history
168
+ merge_commit = find_merge_commit(repo, patch_id, version)
169
+ if merge_commit:
170
+ new_patches[patch_id] = {
171
+ "status": "staged",
172
+ "merge_commit": merge_commit
173
+ }
174
+ else:
175
+ # No merge_commit found, store without it
176
+ new_patches[patch_id] = {"status": "staged"}
177
+ staged_without_commit.append(patch_id)
178
+ else:
179
+ # Unknown status, preserve as-is in dict format
180
+ new_patches[patch_id] = {"status": status}
181
+
182
+ # Update data and write
183
+ data["patches"] = new_patches
184
+
185
+ with toml_file.open('wb') as f:
186
+ tomli_w.dump(data, f)
187
+
188
+ print(f" Converted {len(patches)} patch(es)")
189
+ if staged_without_commit:
190
+ print(f" Warning: No merge_commit found for: {', '.join(staged_without_commit)}",
191
+ file=sys.stderr)
192
+
193
+ migrated_count += 1
194
+
195
+ except Exception as e:
196
+ print(f" Error processing {version}: {e}", file=sys.stderr)
197
+ continue
198
+
199
+ repo.hgit.add('.hop')
200
+
201
+ if migrated_count > 0:
202
+ print(f"\nMigration complete: {migrated_count} file(s) converted to dict format")
203
+ else:
204
+ print("\nNo files needed migration")
@@ -488,61 +488,84 @@ class PatchManager:
488
488
  Apply patch with full release context.
489
489
 
490
490
  Workflow:
491
- 1. Restore DB from production baseline (model/schema.sql)
492
- 2. Apply all release patches in order (RC1, RC2, ..., stage)
493
- 3. If current patch is in release, apply it in correct order
494
- 4. If current patch is NOT in release, apply it at the end
495
- 5. Generate Python code
491
+ 1. Restore DB from release schema (includes all staged patches)
492
+ 2. Apply only the current patch
493
+ 3. Generate Python code
496
494
 
497
- Examples:
498
- # Release context: [123, 456, 789, 234]
499
- # Current patch: 789 (already in release)
495
+ If release schema doesn't exist (backward compatibility), falls back to:
496
+ 1. Restore DB from production baseline
497
+ 2. Apply all staged patches in order
498
+ 3. Apply current patch
499
+ 4. Generate Python code
500
500
 
501
- apply_patch_complete_workflow("789")
501
+ Examples:
502
+ # With release schema (new workflow):
503
+ apply_patch_complete_workflow("999")
502
504
  # Execution:
503
- # 1. Restore DB (1.3.5)
504
- # 2. Apply 123
505
- # 3. Apply 456
506
- # 4. Apply 789 ← In correct order
507
- # 5. Apply 234
508
- # 6. Generate code
509
-
510
- # Current patch: 999 (NOT in release)
505
+ # 1. Restore DB from release-0.17.1.sql (includes staged patches)
506
+ # 2. Apply 999
507
+ # 3. Generate code
508
+
509
+ # Without release schema (backward compat):
511
510
  apply_patch_complete_workflow("999")
512
511
  # Execution:
513
- # 1. Restore DB (1.3.5)
514
- # 2. Apply 123
515
- # 3. Apply 456
516
- # 4. Apply 789
517
- # 5. Apply 234
518
- # 6. Apply 999 ← At the end
519
- # 7. Generate code
512
+ # 1. Restore DB from schema.sql (prod)
513
+ # 2. Apply all staged patches
514
+ # 3. Apply 999
515
+ # 4. Generate code
520
516
  """
521
517
 
522
518
  try:
523
- # Étape 1: Restauration DB
524
- self._repo.restore_database_from_schema()
525
-
526
- # Étape 2: Récupérer contexte release complet
527
- release_patches = self._repo.release_manager.get_all_release_context_patches()
519
+ # Get release version for this patch
520
+ version = self._find_version_for_candidate(patch_id)
521
+ if not version:
522
+ # Try to find from staged patches
523
+ version = self._repo.release_manager.get_next_release_version()
528
524
 
529
525
  applied_release_files = []
530
526
  applied_current_files = []
531
527
  patch_was_in_release = False
532
528
 
533
- # Étape 3: Appliquer patches
534
- for patch in release_patches:
535
- if patch == patch_id:
536
- patch_was_in_release = True
537
- files = self.apply_patch_files(patch, self._repo.model)
538
- applied_release_files.extend(files)
529
+ # Check if release schema exists
530
+ release_schema_path = None
531
+ if version:
532
+ release_schema_path = self._repo.get_release_schema_path(version)
533
+
534
+ if release_schema_path and release_schema_path.exists():
535
+ # New workflow: restore from release schema (includes all staged patches)
536
+ self._repo.restore_database_from_release_schema(version)
539
537
 
540
- # Étape 4: Si patch courant pas dans release, l'appliquer maintenant
541
- if not patch_was_in_release:
538
+ # Apply only the current patch
542
539
  files = self.apply_patch_files(patch_id, self._repo.model)
543
540
  applied_current_files = files
541
+ else:
542
+ # Backward compatibility: old workflow
543
+ # Also generates release schema for migration of existing projects
544
+ self._repo.restore_database_from_schema()
544
545
 
545
- # Étape 5: Génération code Python
546
+ # Get and apply all staged release patches
547
+ release_patches = self._repo.release_manager.get_all_release_context_patches()
548
+
549
+ for patch in release_patches:
550
+ if patch == patch_id:
551
+ patch_was_in_release = True
552
+ files = self.apply_patch_files(patch, self._repo.model)
553
+ applied_release_files.extend(files)
554
+
555
+ # Generate release schema for existing projects migration
556
+ # This captures the state after all staged patches are applied
557
+ if version:
558
+ try:
559
+ self._repo.generate_release_schema(version)
560
+ except Exception:
561
+ pass # Non-critical, continue with apply
562
+
563
+ # If current patch not in release (candidate), apply it now
564
+ if not patch_was_in_release:
565
+ files = self.apply_patch_files(patch_id, self._repo.model)
566
+ applied_current_files = files
567
+
568
+ # Generate Python code
546
569
  # Track generated files
547
570
  package_dir = Path(self._base_dir) / self._repo_name
548
571
  files_before = set()
@@ -557,14 +580,14 @@ class PatchManager:
557
580
 
558
581
  generated_files = [str(f.relative_to(self._base_dir)) for f in files_after]
559
582
 
560
- # Étape 6: Retour succès
583
+ # Return success
561
584
  return {
562
585
  'patch_id': patch_id,
563
- 'release_patches': [p for p in release_patches if p != patch_id],
564
586
  'applied_release_files': applied_release_files,
565
587
  'applied_current_files': applied_current_files,
566
588
  'patch_was_in_release': patch_was_in_release,
567
589
  'generated_files': generated_files,
590
+ 'used_release_schema': release_schema_path is not None and release_schema_path.exists(),
568
591
  'status': 'success',
569
592
  'error': None
570
593
  }
@@ -619,9 +642,11 @@ class PatchManager:
619
642
  # Apply files in lexicographic order
620
643
  for patch_file in structure.files:
621
644
  if patch_file.is_sql:
645
+ print('XXX', patch_file.name)
622
646
  self._execute_sql_file(patch_file.path, database_model)
623
647
  applied_files.append(patch_file.name)
624
648
  elif patch_file.is_python:
649
+ print('XXX', patch_file.name)
625
650
  self._execute_python_file(patch_file.path)
626
651
  applied_files.append(patch_file.name)
627
652
  # Other file types are ignored (not executed)
@@ -1909,8 +1934,17 @@ class PatchManager:
1909
1934
  f"You may need to resolve conflicts manually."
1910
1935
  )
1911
1936
 
1912
- # 6. Move from candidates to stage
1913
- self._move_patch_to_stage(patch_id, version)
1937
+ # 5b. Get merge commit hash
1938
+ merge_commit = self._repo.hgit.last_commit()
1939
+
1940
+ # 6. Move from candidates to stage (with merge commit hash)
1941
+ self._move_patch_to_stage(patch_id, version, merge_commit)
1942
+
1943
+ # 6b. Regenerate release schema (DB is already in correct state after validation)
1944
+ try:
1945
+ self._update_release_schemas(version)
1946
+ except Exception as e:
1947
+ raise PatchManagerError(f"Failed to update release schema: {e}")
1914
1948
 
1915
1949
  # 7. Commit changes on release branch (TOML file is in .hop/releases/)
1916
1950
  # This also syncs .hop/ to all active branches automatically via decorator
@@ -1921,6 +1955,9 @@ class PatchManager:
1921
1955
  except Exception as e:
1922
1956
  raise PatchManagerError(f"Failed to commit/push changes: {e}")
1923
1957
 
1958
+ # 7b. Propagate release schema to higher version releases (now that commit is done)
1959
+ self._propagate_release_schema_to_higher_versions(version)
1960
+
1924
1961
  # 8. Delete patch branch (local and remote)
1925
1962
  try:
1926
1963
  self._repo.hgit.delete_local_branch(patch_branch)
@@ -1936,6 +1973,129 @@ class PatchManager:
1936
1973
  'merged_into': release_branch
1937
1974
  }
1938
1975
 
1976
+ def _update_release_schemas(self, version: str) -> None:
1977
+ """
1978
+ Update release schema for current version and propagate to higher versions.
1979
+
1980
+ After a patch is merged, regenerates the release schema file for the
1981
+ current version and updates all higher version releases that depend on it.
1982
+
1983
+ Args:
1984
+ version: Current release version (e.g., "0.17.1")
1985
+
1986
+ Workflow:
1987
+ 1. Add release schema to staging (already generated during validation)
1988
+ 2. Find all release branches with higher versions
1989
+ 3. For each higher version:
1990
+ - Checkout to that branch
1991
+ - Restore DB from current release schema
1992
+ - Apply all staged patches for that release
1993
+ - Regenerate its release schema
1994
+ - Commit the updated schema
1995
+ 4. Return to original branch
1996
+ """
1997
+ from packaging.version import Version
1998
+ from half_orm_dev.release_file import ReleaseFile
1999
+
2000
+ original_branch = self._repo.hgit.branch
2001
+ current_ver = Version(version)
2002
+
2003
+ # 1. Write and add release schema to staging area (if not hotfix mode)
2004
+ # Schema content was saved during validation with correct DB state
2005
+ release_schema_path = self._repo.get_release_schema_path(version)
2006
+ if hasattr(self, '_pending_release_schema_content') and self._pending_release_schema_content:
2007
+ click.echo(f" • Writing release schema ({len(self._pending_release_schema_content)} bytes)")
2008
+ release_schema_path.write_text(self._pending_release_schema_content, encoding='utf-8')
2009
+ self._pending_release_schema_content = None # Clear after use
2010
+ self._repo.hgit.add(str(release_schema_path))
2011
+ else:
2012
+ # Hotfix mode or no content - skip release schema
2013
+ click.echo(f" • Skipping release schema (hotfix mode)")
2014
+
2015
+ # NOTE: Do NOT checkout other branches here!
2016
+ # The commit will be done later by commit_and_sync_to_active_branches()
2017
+ # Propagation to higher releases is disabled for now as it causes issues
2018
+ # with uncommitted changes being lost during checkout.
2019
+ # TODO: Re-enable propagation after the main commit is done.
2020
+
2021
+ # 2. Find higher version releases (disabled - propagation moved to after commit)
2022
+ releases_dir = Path(self._repo.releases_dir)
2023
+ higher_releases = []
2024
+
2025
+ for toml_file in releases_dir.glob("*-patches.toml"):
2026
+ rel_version = toml_file.stem.replace('-patches', '')
2027
+ try:
2028
+ rel_ver = Version(rel_version)
2029
+ if rel_ver > current_ver:
2030
+ higher_releases.append(rel_version)
2031
+ except Exception:
2032
+ continue
2033
+
2034
+ # Sort by version (ascending)
2035
+ higher_releases.sort(key=lambda v: Version(v))
2036
+
2037
+ # Store higher releases for later propagation (after commit)
2038
+ # This avoids losing uncommitted changes when checking out other branches
2039
+ self._pending_higher_releases = higher_releases if higher_releases else None
2040
+
2041
+ def _propagate_release_schema_to_higher_versions(self, version: str) -> None:
2042
+ """
2043
+ Propagate release schema changes to higher version releases.
2044
+
2045
+ Called after commit to update release schemas for all releases
2046
+ with version > current version.
2047
+
2048
+ Args:
2049
+ version: Current release version that was just updated
2050
+ """
2051
+ from packaging.version import Version
2052
+ from half_orm_dev.release_file import ReleaseFile
2053
+
2054
+ if not hasattr(self, '_pending_higher_releases') or not self._pending_higher_releases:
2055
+ return
2056
+
2057
+ higher_releases = self._pending_higher_releases
2058
+ self._pending_higher_releases = None # Clear after use
2059
+
2060
+ original_branch = self._repo.hgit.branch
2061
+ releases_dir = Path(self._repo.releases_dir)
2062
+
2063
+ for higher_version in higher_releases:
2064
+ higher_branch = f"ho-release/{higher_version}"
2065
+
2066
+ if not self._repo.hgit.branch_exists(higher_branch):
2067
+ continue
2068
+
2069
+ click.echo(f" • Propagating to {higher_branch}...")
2070
+
2071
+ try:
2072
+ # Checkout to higher version branch
2073
+ self._repo.hgit.checkout(higher_branch)
2074
+
2075
+ # Restore DB from current release schema (which includes the new patch)
2076
+ self._repo.restore_database_from_release_schema(version)
2077
+
2078
+ # Apply all staged patches for this higher release
2079
+ release_file = ReleaseFile(higher_version, releases_dir)
2080
+ if release_file.exists():
2081
+ staged_patches = release_file.get_patches(status="staged")
2082
+ for pid in staged_patches:
2083
+ patch_dir = Path(self._base_dir) / "Patches" / pid
2084
+ if patch_dir.exists():
2085
+ self.apply_patch_files(pid, self._repo.model)
2086
+
2087
+ # Regenerate release schema for this higher version
2088
+ higher_schema_path = self._repo.generate_release_schema(higher_version)
2089
+ self._repo.hgit.add(str(higher_schema_path))
2090
+ self._repo.hgit.commit('-m', f"[HOP] Update release schema from %{version}")
2091
+ self._repo.hgit.push()
2092
+
2093
+ except Exception as e:
2094
+ click.echo(f" ⚠️ Warning: Failed to propagate to {higher_branch}: {e}")
2095
+
2096
+ # Return to original branch
2097
+ self._repo.hgit.checkout(original_branch)
2098
+
1939
2099
  def _validate_patch_before_merge(
1940
2100
  self,
1941
2101
  patch_id: str,
@@ -1976,6 +2136,8 @@ class PatchManager:
1976
2136
  # Save current branch
1977
2137
  original_branch = self._repo.hgit.branch
1978
2138
  temp_branch = f"ho-validate/{patch_id}"
2139
+ release_schema_content = None
2140
+ release_schema_path = None
1979
2141
 
1980
2142
  try:
1981
2143
  click.echo(f"\n🔍 Validating patch {utils.Color.bold(patch_id)} before merge...")
@@ -1998,24 +2160,36 @@ class PatchManager:
1998
2160
  # 3. Run patch apply and verify no modifications
1999
2161
  click.echo(f" • Running patch apply to verify idempotency...")
2000
2162
  try:
2001
- # Get list of staged patches for this version from TOML file
2002
- release_file = ReleaseFile(version, Path(self._repo.releases_dir))
2003
- staged_patches = []
2004
- if release_file.exists():
2005
- staged_patches = release_file.get_patches(status="staged")
2163
+ # Check if release schema exists
2164
+ release_schema_path = self._repo.get_release_schema_path(version)
2006
2165
 
2007
- # Apply all staged patches + current patch
2008
- all_patches = staged_patches + [patch_id]
2166
+ if release_schema_path.exists():
2167
+ # New workflow: restore from release schema (includes all staged patches)
2168
+ self._repo.restore_database_from_release_schema(version)
2009
2169
 
2010
- # Restore database and apply patches
2011
- self._repo.restore_database_from_schema()
2012
-
2013
- for pid in all_patches:
2014
- patch_dir = Path(self._repo.base_dir) / "Patches" / pid
2170
+ # Apply only the current patch
2171
+ patch_dir = Path(self._repo.base_dir) / "Patches" / patch_id
2015
2172
  if patch_dir.exists():
2016
- self.apply_patch_files(pid, self._repo.model)
2173
+ self.apply_patch_files(patch_id, self._repo.model)
2174
+ else:
2175
+ # Fallback: old workflow for backward compatibility
2176
+ release_file = ReleaseFile(version, Path(self._repo.releases_dir))
2177
+ staged_patches = []
2178
+ if release_file.exists():
2179
+ staged_patches = release_file.get_patches(status="staged")
2180
+
2181
+ # Apply all staged patches + current patch
2182
+ all_patches = staged_patches + [patch_id]
2183
+
2184
+ # Restore database and apply patches
2185
+ self._repo.restore_database_from_schema()
2017
2186
 
2018
- # Generate modules
2187
+ for pid in all_patches:
2188
+ patch_dir = Path(self._repo.base_dir) / "Patches" / pid
2189
+ if patch_dir.exists():
2190
+ self.apply_patch_files(pid, self._repo.model)
2191
+
2192
+ # Generate modules
2019
2193
  modules.generate(self._repo)
2020
2194
 
2021
2195
  # Check if any files were modified
@@ -2043,10 +2217,33 @@ class PatchManager:
2043
2217
  # 4. Run tests (best-effort)
2044
2218
  self._run_tests_if_available()
2045
2219
 
2220
+ # 5. Generate release schema while DB is in correct state
2221
+ # This captures prod + all staged patches + current patch
2222
+ # Skip for hotfix releases (detected by presence of X.Y.Z.txt production file)
2223
+ prod_file = Path(self._repo.releases_dir) / f"{version}.txt"
2224
+ is_hotfix = prod_file.exists()
2225
+
2226
+ if is_hotfix:
2227
+ click.echo(f" • Skipping release schema (hotfix mode)")
2228
+ release_schema_content = None
2229
+ else:
2230
+ click.echo(f" • Generating release schema...")
2231
+ release_schema_path = self._repo.generate_release_schema(version)
2232
+
2233
+ # Save schema content to restore after branch checkout
2234
+ # (the file will be lost when switching branches)
2235
+ release_schema_content = release_schema_path.read_text(encoding='utf-8')
2236
+
2237
+ # Delete the file to avoid checkout conflicts
2238
+ # (content is saved in memory and will be written after checkout)
2239
+ release_schema_path.unlink()
2240
+
2241
+ click.echo(f" • {utils.Color.green('✓')} Release schema generated")
2242
+
2046
2243
  click.echo(f" • {utils.Color.green('✓')} Validation passed!\n")
2047
2244
 
2048
2245
  finally:
2049
- # 5. Cleanup: Delete temp branch and return to original branch
2246
+ # 6. Cleanup: Delete temp branch and return to original branch
2050
2247
  try:
2051
2248
  # Return to original branch
2052
2249
  if self._repo.hgit.branch != original_branch:
@@ -2059,6 +2256,10 @@ class PatchManager:
2059
2256
  # Cleanup errors are non-critical, just warn
2060
2257
  click.echo(f"⚠️ Warning: Failed to cleanup temp branch {temp_branch}: {e}")
2061
2258
 
2259
+ # Store release schema content for later use in _update_release_schemas
2260
+ # (after merge, when we're on the release branch)
2261
+ self._pending_release_schema_content = release_schema_content
2262
+
2062
2263
  def _run_tests_if_available(self) -> None:
2063
2264
  """
2064
2265
  Run tests if test configuration is available.
@@ -2631,7 +2832,7 @@ class PatchManager:
2631
2832
 
2632
2833
  return '\n'.join(lines)
2633
2834
 
2634
- def _move_patch_to_stage(self, patch_id: str, version: str) -> None:
2835
+ def _move_patch_to_stage(self, patch_id: str, version: str, merge_commit: str) -> None:
2635
2836
  """
2636
2837
  Move patch from candidate to staged status.
2637
2838
 
@@ -2641,19 +2842,21 @@ class PatchManager:
2641
2842
  Args:
2642
2843
  patch_id: Patch identifier to move
2643
2844
  version: Release version
2845
+ merge_commit: Git commit hash of the merge commit
2644
2846
 
2645
2847
  Raises:
2646
2848
  PatchManagerError: If operation fails
2647
2849
 
2648
2850
  Examples:
2649
- self._move_patch_to_stage("456-user-auth", "0.17.0")
2650
- # Changes "456-user-auth" = "candidate" to "456-user-auth" = "staged"
2851
+ self._move_patch_to_stage("456-user-auth", "0.17.0", "abc123de")
2852
+ # Changes "456-user-auth" = {status = "candidate"}
2853
+ # to "456-user-auth" = {status = "staged", merge_commit = "abc123de"}
2651
2854
  # Order is preserved!
2652
2855
  """
2653
2856
  release_file = ReleaseFile(version, self._releases_dir)
2654
2857
 
2655
2858
  try:
2656
- release_file.move_to_staged(patch_id)
2859
+ release_file.move_to_staged(patch_id, merge_commit)
2657
2860
 
2658
2861
  # Stage file for commit
2659
2862
  self._repo.hgit.add(str(release_file.file_path))