half-orm-dev 0.17.5a3__tar.gz → 0.17.5a5__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 (72) hide show
  1. {half_orm_dev-0.17.5a3/half_orm_dev.egg-info → half_orm_dev-0.17.5a5}/PKG-INFO +1 -1
  2. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/bootstrap_manager.py +41 -13
  3. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/decorators.py +15 -4
  4. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/patch_manager.py +110 -29
  5. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/release_manager.py +21 -102
  6. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/repo.py +36 -8
  7. half_orm_dev-0.17.5a5/half_orm_dev/version.txt +1 -0
  8. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5/half_orm_dev.egg-info}/PKG-INFO +1 -1
  9. half_orm_dev-0.17.5a3/half_orm_dev/version.txt +0 -1
  10. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/AUTHORS +0 -0
  11. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/LICENSE +0 -0
  12. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/README.md +0 -0
  13. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/__init__.py +0 -0
  14. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/__init__.py +0 -0
  15. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/__init__.py +0 -0
  16. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/apply.py +0 -0
  17. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/bootstrap.py +0 -0
  18. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/check.py +0 -0
  19. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/clone.py +0 -0
  20. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/init.py +0 -0
  21. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/migrate.py +0 -0
  22. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/patch.py +0 -0
  23. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/release.py +0 -0
  24. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/restore.py +0 -0
  25. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/sync.py +0 -0
  26. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/todo.py +0 -0
  27. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/undo.py +0 -0
  28. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/update.py +0 -0
  29. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/commands/upgrade.py +0 -0
  30. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli/main.py +0 -0
  31. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/cli_extension.py +0 -0
  32. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/database.py +0 -0
  33. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/file_executor.py +0 -0
  34. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/hgit.py +0 -0
  35. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/migration_manager.py +0 -0
  36. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  37. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  38. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
  39. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
  40. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
  41. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
  42. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/modules.py +0 -0
  43. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/patch_validator.py +0 -0
  44. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  45. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  46. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  47. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/patches/log +0 -0
  48. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  49. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/release_file.py +0 -0
  50. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/scripts/repair-metadata.py +0 -0
  51. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/.gitignore +0 -0
  52. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/MANIFEST.in +0 -0
  53. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/README +0 -0
  54. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/conftest_template +0 -0
  55. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  56. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  57. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/init_module_template +0 -0
  58. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/module_template_1 +0 -0
  59. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/module_template_2 +0 -0
  60. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/module_template_3 +0 -0
  61. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/pyproject.toml +0 -0
  62. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/relation_test +0 -0
  63. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/sql_adapter +0 -0
  64. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/templates/warning +0 -0
  65. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev/utils.py +0 -0
  66. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev.egg-info/SOURCES.txt +0 -0
  67. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  68. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev.egg-info/requires.txt +0 -0
  69. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/half_orm_dev.egg-info/top_level.txt +0 -0
  70. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/pyproject.toml +0 -0
  71. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/setup.cfg +0 -0
  72. {half_orm_dev-0.17.5a3 → half_orm_dev-0.17.5a5}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 0.17.5a3
3
+ Version: 0.17.5a5
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
@@ -78,7 +78,12 @@ class BootstrapManager:
78
78
  # Schema might not exist yet, ignore
79
79
  pass
80
80
 
81
- def get_bootstrap_files(self, up_to_version: Optional[str] = None) -> List[Path]:
81
+ def get_bootstrap_files(
82
+ self,
83
+ up_to_version: Optional[str] = None,
84
+ exclude_version: Optional[str] = None,
85
+ for_version: Optional[str] = None
86
+ ) -> List[Path]:
82
87
  """
83
88
  List bootstrap files sorted by numeric prefix.
84
89
 
@@ -87,8 +92,11 @@ class BootstrapManager:
87
92
 
88
93
  Args:
89
94
  up_to_version: If provided, only return files with version <= this version.
90
- Used during restore_database_from_schema to avoid executing
91
- bootstraps for future releases.
95
+ exclude_version: If provided, exclude files for exactly this version.
96
+ Used during promote to skip the version being promoted
97
+ (its bootstraps run after patches are applied).
98
+ for_version: If provided, only return files for exactly this version.
99
+ Used after patches to run only the promoted version's bootstraps.
92
100
 
93
101
  Returns:
94
102
  List of Path objects for bootstrap files in execution order
@@ -103,11 +111,20 @@ class BootstrapManager:
103
111
  if not re.match(r'^\d+-', file_path.name):
104
112
  continue
105
113
 
106
- # If up_to_version is specified, filter out files from newer versions
107
- if up_to_version:
108
- file_version = self._extract_version_from_filename(file_path.name)
109
- if file_version != 'unknown' and not self._version_le(file_version, up_to_version):
114
+ file_version = self._extract_version_from_filename(file_path.name)
115
+
116
+ # If for_version is specified, only include files for exactly this version
117
+ if for_version:
118
+ if file_version != for_version:
119
+ continue
120
+ else:
121
+ # If exclude_version is specified, skip files for that version
122
+ if exclude_version and file_version == exclude_version:
110
123
  continue
124
+ # If up_to_version is specified, filter out files from newer versions
125
+ if up_to_version:
126
+ if file_version != 'unknown' and not self._version_le(file_version, up_to_version):
127
+ continue
111
128
 
112
129
  files.append(file_path)
113
130
 
@@ -140,17 +157,24 @@ class BootstrapManager:
140
157
  # Table might not exist yet (pre-migration)
141
158
  return set()
142
159
 
143
- def get_pending_files(self, up_to_version: Optional[str] = None) -> List[Path]:
160
+ def get_pending_files(
161
+ self,
162
+ up_to_version: Optional[str] = None,
163
+ exclude_version: Optional[str] = None,
164
+ for_version: Optional[str] = None
165
+ ) -> List[Path]:
144
166
  """
145
167
  Get bootstrap files not yet executed.
146
168
 
147
169
  Args:
148
170
  up_to_version: If provided, only return files with version <= this version.
171
+ exclude_version: If provided, exclude files for exactly this version.
172
+ for_version: If provided, only return files for exactly this version.
149
173
 
150
174
  Returns:
151
175
  List of Path objects for files pending execution
152
176
  """
153
- all_files = self.get_bootstrap_files(up_to_version)
177
+ all_files = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
154
178
  executed = self.get_executed_files()
155
179
 
156
180
  return [f for f in all_files if f.name not in executed]
@@ -201,7 +225,9 @@ class BootstrapManager:
201
225
  dry_run: bool = False,
202
226
  force: bool = False,
203
227
  exclude_patch_id: Optional[str] = None,
204
- up_to_version: Optional[str] = None
228
+ up_to_version: Optional[str] = None,
229
+ exclude_version: Optional[str] = None,
230
+ for_version: Optional[str] = None
205
231
  ) -> dict:
206
232
  """
207
233
  Execute pending bootstrap files.
@@ -215,6 +241,8 @@ class BootstrapManager:
215
241
  up_to_version: If provided, only execute files with version <= this version.
216
242
  Used during restore_database_from_schema to avoid executing
217
243
  bootstraps for future releases.
244
+ exclude_version: If provided, exclude files for exactly this version.
245
+ for_version: If provided, only execute files for exactly this version.
218
246
 
219
247
  Returns:
220
248
  Dict with execution results:
@@ -231,11 +259,11 @@ class BootstrapManager:
231
259
  }
232
260
 
233
261
  if force:
234
- files_to_execute = self.get_bootstrap_files(up_to_version)
262
+ files_to_execute = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
235
263
  else:
236
- files_to_execute = self.get_pending_files(up_to_version)
264
+ files_to_execute = self.get_pending_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
237
265
  # Calculate skipped
238
- all_files = self.get_bootstrap_files(up_to_version)
266
+ all_files = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
239
267
  executed = self.get_executed_files()
240
268
  result['skipped'] = [f.name for f in all_files if f.name in executed]
241
269
 
@@ -4,6 +4,7 @@ Decorators for half-orm-dev.
4
4
  Provides common decorators for ReleaseManager and PatchManager.
5
5
  """
6
6
 
7
+ import signal
7
8
  import sys
8
9
  import inspect
9
10
  from functools import wraps
@@ -13,9 +14,6 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
13
14
  """
14
15
  Decorator to protect methods with a dynamic branch lock.
15
16
 
16
- Unlike with_branch_lock which uses a static branch name, this decorator
17
- calls a function to determine the branch name at runtime.
18
-
19
17
  IMPORTANT: Automatically syncs .hop/ directory to all other active branches
20
18
  after the decorated method completes (from locked branch to all others).
21
19
 
@@ -77,8 +75,21 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
77
75
  return result
78
76
  finally:
79
77
  # Always release lock (even on error)
78
+ # Block SIGINT during cleanup to prevent Ctrl+C from
79
+ # interrupting lock release and leaving an orphan tag.
80
80
  if lock_tag:
81
- self._repo.hgit.release_branch_lock(lock_tag)
81
+ interrupted = False
82
+ original_handler = signal.getsignal(signal.SIGINT)
83
+ signal.signal(signal.SIGINT, lambda s, f: setattr(
84
+ wrapper, '_interrupted', True) or None)
85
+ wrapper._interrupted = False
86
+ try:
87
+ self._repo.hgit.release_branch_lock(lock_tag)
88
+ finally:
89
+ interrupted = wrapper._interrupted
90
+ signal.signal(signal.SIGINT, original_handler)
91
+ if interrupted:
92
+ raise KeyboardInterrupt()
82
93
 
83
94
  return wrapper
84
95
  return decorator
@@ -572,9 +572,10 @@ class PatchManager:
572
572
 
573
573
  if release_schema_path and release_schema_path.exists():
574
574
  # New workflow: restore from release schema (includes all staged patches)
575
- # Bootstrap scripts run automatically, excluding current patch
575
+ # Exclude bootstraps for this version apply_patch_files will
576
+ # execute the current patch's bootstrap below.
576
577
  self._repo.restore_database_from_release_schema(
577
- version, exclude_bootstrap_patch_id=patch_id
578
+ version, exclude_bootstrap_version=version
578
579
  )
579
580
 
580
581
  # Apply only the current patch
@@ -583,9 +584,10 @@ class PatchManager:
583
584
  else:
584
585
  # Backward compatibility: old workflow
585
586
  # Also generates release schema for migration of existing projects
586
- # Bootstrap scripts run automatically, excluding current patch
587
+ # Exclude bootstraps for this version apply_patch_files will
588
+ # execute them when applying staged patches below.
587
589
  self._repo.restore_database_from_schema(
588
- exclude_bootstrap_patch_id=patch_id
590
+ exclude_bootstrap_version=version
589
591
  )
590
592
 
591
593
  # Get and apply all staged release patches
@@ -692,11 +694,6 @@ class PatchManager:
692
694
 
693
695
  # Apply files in lexicographic order
694
696
  for patch_file in structure.files:
695
- # Skip bootstrap files - they are executed via run_bootstrap() after merge
696
- if is_bootstrap_file(patch_file.path):
697
- click.echo(f" • {patch_file.name} (bootstrap - skipped, will run after merge)")
698
- continue
699
-
700
697
  if patch_file.is_sql:
701
698
  click.echo(f" • {patch_file.name}")
702
699
  try:
@@ -1061,6 +1058,11 @@ class PatchManager:
1061
1058
  shutil.copy(file_path, dest)
1062
1059
  copied.append(new_name)
1063
1060
 
1061
+ # Record as already executed — the bootstrap SQL was already
1062
+ # run by apply_patch_files during validation, so run_bootstrap
1063
+ # must skip it to avoid double execution.
1064
+ bootstrap_mgr.record_execution(new_name, version)
1065
+
1064
1066
  click.echo(f" • Copied bootstrap file: {new_name}")
1065
1067
 
1066
1068
  return copied
@@ -2033,11 +2035,15 @@ class PatchManager:
2033
2035
  # 5. Merge patch branch into release branch
2034
2036
  try:
2035
2037
  self._repo.hgit.merge(patch_branch, message=f'''[HOP] Merge #{patch_id} into %"{version}"''')
2036
- except Exception as e:
2037
- raise PatchManagerError(
2038
- f"Failed to merge {patch_branch} into {release_branch}: {e}\n"
2039
- f"You may need to resolve conflicts manually."
2040
- )
2038
+ except (GitCommandError, Exception) as e:
2039
+ # Auto-resolve conflicts in generated files (package dir).
2040
+ # Generated files are regenerated by modules.generate() so
2041
+ # conflicts there can be safely auto-resolved.
2042
+ if not self._auto_resolve_generated_conflicts(e, patch_branch, release_branch):
2043
+ raise PatchManagerError(
2044
+ f"Failed to merge {patch_branch} into {release_branch}: {e}\n"
2045
+ f"You may need to resolve conflicts manually."
2046
+ )
2041
2047
 
2042
2048
  # 5b. Get merge commit hash
2043
2049
  merge_commit = self._repo.hgit.last_commit()
@@ -2212,6 +2218,55 @@ class PatchManager:
2212
2218
  # Return to original branch
2213
2219
  self._repo.hgit.checkout(original_branch)
2214
2220
 
2221
+ def _auto_resolve_generated_conflicts(
2222
+ self,
2223
+ merge_error: Exception,
2224
+ patch_branch: str,
2225
+ target_branch: str = None
2226
+ ) -> bool:
2227
+ """
2228
+ Auto-resolve merge conflicts when they only affect generated files.
2229
+
2230
+ Generated files (under the package directory) are regenerated by
2231
+ modules.generate() after patch apply, so conflicts there are harmless
2232
+ and can be safely resolved by accepting either side.
2233
+
2234
+ Args:
2235
+ merge_error: The exception from the failed merge
2236
+ patch_branch: The patch branch being merged
2237
+ target_branch: Optional target branch name (for error messages)
2238
+
2239
+ Returns:
2240
+ True if conflicts were auto-resolved, False otherwise
2241
+ """
2242
+ package_name = self._repo.name
2243
+ git = self._repo.hgit._HGit__git_repo.git
2244
+
2245
+ try:
2246
+ diff_output = git.diff('--name-only', '--diff-filter=U')
2247
+ if not isinstance(diff_output, str) or not diff_output.strip():
2248
+ return False
2249
+ conflicted = diff_output.strip().splitlines()
2250
+ except Exception:
2251
+ return False
2252
+
2253
+ non_generated = [
2254
+ f for f in conflicted
2255
+ if not f.startswith(f"{package_name}/")
2256
+ ]
2257
+
2258
+ if non_generated:
2259
+ return False
2260
+
2261
+ # All conflicts are in generated files — auto-resolve
2262
+ # and conclude the interrupted merge commit.
2263
+ click.echo(f" • Auto-resolving conflicts in generated files...")
2264
+ for f in conflicted:
2265
+ git.checkout('--theirs', f)
2266
+ git.add(*conflicted)
2267
+ git.commit('--no-verify', '--no-edit')
2268
+ return True
2269
+
2215
2270
  def _validate_patch_before_merge(
2216
2271
  self,
2217
2272
  patch_id: str,
@@ -2267,11 +2322,15 @@ class PatchManager:
2267
2322
  click.echo(f" • Merging {patch_branch} into temp branch...")
2268
2323
  try:
2269
2324
  self._repo.hgit.merge(patch_branch, message=f"[VALIDATE] Test merge #{patch_id}")
2270
- except Exception as e:
2271
- raise PatchManagerError(
2272
- f"Failed to merge {patch_branch} during validation: {e}\n"
2273
- f"Please resolve conflicts before closing the patch."
2274
- )
2325
+ except (GitCommandError, Exception) as e:
2326
+ # Auto-resolve conflicts in generated files (package dir).
2327
+ # Generated files are regenerated by modules.generate() so
2328
+ # conflicts there can be safely auto-resolved.
2329
+ if not self._auto_resolve_generated_conflicts(e, patch_branch):
2330
+ raise PatchManagerError(
2331
+ f"Failed to merge {patch_branch} during validation: {e}\n"
2332
+ f"Please resolve conflicts before closing the patch."
2333
+ )
2275
2334
 
2276
2335
  # 3. Run patch apply and verify no modifications
2277
2336
  click.echo(f" • Running patch apply to verify idempotency...")
@@ -2308,18 +2367,31 @@ class PatchManager:
2308
2367
  # Generate modules
2309
2368
  modules.generate(self._repo)
2310
2369
 
2311
- # Check if any files were modified
2370
+ # Stage generated files (package dir) — they are always
2371
+ # regenerated from DB state so changes there are expected.
2372
+ package_name = self._repo.name
2373
+ package_dir = Path(self._repo.base_dir) / package_name
2374
+ if package_dir.exists():
2375
+ self._repo.hgit.add(str(package_dir))
2376
+
2377
+ # Check if any NON-generated files were modified
2312
2378
  if not self._repo.hgit.repos_is_clean():
2313
2379
  modified_files = self._repo.hgit.get_modified_files()
2314
- raise PatchManagerError(
2315
- f"Patch validation failed: patch apply modified files!\n"
2316
- f"This indicates the patch is not idempotent or schema is out of sync.\n\n"
2317
- f"Modified files:\n" + "\n".join(f"{f}" for f in modified_files) + "\n\n"
2318
- f"Actions required:\n"
2319
- f" 1. Verify patch SQL is idempotent (uses CREATE IF NOT EXISTS, etc.)\n"
2320
- f" 2. Ensure schema.sql is up to date with all previous patches\n"
2321
- f" 3. Run 'half_orm dev patch apply' on your patch branch to test"
2322
- )
2380
+ # Filter out generated files (already staged)
2381
+ non_generated = [
2382
+ f for f in modified_files
2383
+ if not f.startswith(f"{package_name}/")
2384
+ ]
2385
+ if non_generated:
2386
+ raise PatchManagerError(
2387
+ f"Patch validation failed: patch apply modified files!\n"
2388
+ f"This indicates the patch is not idempotent or schema is out of sync.\n\n"
2389
+ f"Modified files:\n" + "\n".join(f" • {f}" for f in non_generated) + "\n\n"
2390
+ f"Actions required:\n"
2391
+ f" 1. Verify patch SQL is idempotent (uses CREATE IF NOT EXISTS, etc.)\n"
2392
+ f" 2. Ensure schema.sql is up to date with all previous patches\n"
2393
+ f" 3. Run 'half_orm dev patch apply' on your patch branch to test"
2394
+ )
2323
2395
 
2324
2396
  click.echo(f" • {utils.Color.green('✓')} Patch apply succeeded with no modifications")
2325
2397
 
@@ -2361,6 +2433,15 @@ class PatchManager:
2361
2433
  finally:
2362
2434
  # 6. Cleanup: Delete temp branch and return to original branch
2363
2435
  try:
2436
+ # Discard any staged/unstaged changes in the generated package
2437
+ # dir so checkout doesn't fail (they were staged during validation).
2438
+ package_name = self._repo.name
2439
+ try:
2440
+ self._repo.hgit._HGit__git_repo.git.reset('HEAD', '--', f'{package_name}/')
2441
+ self._repo.hgit._HGit__git_repo.git.checkout('--', f'{package_name}/')
2442
+ except Exception:
2443
+ pass
2444
+
2364
2445
  # Return to original branch
2365
2446
  if self._repo.hgit.branch != original_branch:
2366
2447
  self._repo.hgit.checkout(original_branch)
@@ -733,7 +733,7 @@ class ReleaseManager:
733
733
 
734
734
  return patches
735
735
 
736
- def _apply_release_patches(self, version: str, hotfix=False, force_apply=False) -> None:
736
+ def _apply_release_patches(self, version: str, hotfix=False, force_apply=False, exclude_bootstrap_version: str = None) -> None:
737
737
  """
738
738
  Apply all patches for a release version to the database.
739
739
 
@@ -751,6 +751,8 @@ class ReleaseManager:
751
751
  hotfix: If True, skip RC patches (hotfix workflow)
752
752
  force_apply: If True, always apply patches individually even if
753
753
  release schema exists (used for production validation)
754
+ exclude_bootstrap_version: If provided, skip bootstrap scripts for
755
+ this version during restore (they will be run after patches).
754
756
 
755
757
  Raises:
756
758
  ReleaseManagerError: If patch application fails
@@ -759,11 +761,15 @@ class ReleaseManager:
759
761
  release_schema_path = self._repo.get_release_schema_path(version)
760
762
  if release_schema_path.exists() and not force_apply:
761
763
  # New workflow: restore from release schema (already contains all staged patches)
762
- self._repo.restore_database_from_release_schema(version)
764
+ self._repo.restore_database_from_release_schema(
765
+ version, exclude_bootstrap_version=exclude_bootstrap_version
766
+ )
763
767
  return
764
768
 
765
769
  # Fallback: old workflow - restore database from baseline
766
- self._repo.restore_database_from_schema()
770
+ self._repo.restore_database_from_schema(
771
+ exclude_bootstrap_version=exclude_bootstrap_version
772
+ )
767
773
 
768
774
  current_branch = self._repo.hgit.branch
769
775
 
@@ -821,7 +827,7 @@ class ReleaseManager:
821
827
  # Return to original branch
822
828
  self._repo.hgit.checkout(current_branch)
823
829
 
824
- def _run_bootstrap_scripts(self, up_to_version: str = None) -> None:
830
+ def _run_bootstrap_scripts(self, up_to_version: str = None, for_version: str = None) -> None:
825
831
  """
826
832
  Execute pending bootstrap scripts after patch application.
827
833
 
@@ -830,7 +836,7 @@ class ReleaseManager:
830
836
 
831
837
  Args:
832
838
  up_to_version: If provided, only execute bootstraps for versions <= this.
833
- Used to avoid running bootstraps for future releases.
839
+ for_version: If provided, only execute bootstraps for exactly this version.
834
840
 
835
841
  Raises:
836
842
  ReleaseManagerError: If bootstrap execution fails
@@ -844,14 +850,14 @@ class ReleaseManager:
844
850
  return
845
851
 
846
852
  # Get pending files (filtered by version if specified)
847
- pending = bootstrap_mgr.get_pending_files(up_to_version)
853
+ pending = bootstrap_mgr.get_pending_files(up_to_version, for_version=for_version)
848
854
  if not pending:
849
855
  return
850
856
 
851
857
  print(f"\n📦 Executing {len(pending)} bootstrap script(s)...")
852
858
 
853
859
  try:
854
- result = bootstrap_mgr.run_bootstrap(up_to_version=up_to_version)
860
+ result = bootstrap_mgr.run_bootstrap(up_to_version=up_to_version, for_version=for_version)
855
861
 
856
862
  if result['errors']:
857
863
  errors = result['errors']
@@ -896,82 +902,6 @@ class ReleaseManager:
896
902
 
897
903
  return all_patches
898
904
 
899
- def _generate_data_sql_file(self, patch_list: List[str], version: str) -> Optional[Path]:
900
- """
901
- Generate model/data-X.Y.Z.sql file from patches with @HOP:data annotation.
902
-
903
- Collects all SQL files marked with `-- @HOP:data` from the patch list
904
- and concatenates them into a single data SQL file for from-scratch
905
- installations (clone, restore_database_from_schema).
906
-
907
- This file is only generated for production releases. RC and hotfix
908
- versions don't need this file because:
909
- - In production upgrades, data is inserted by patch application
910
- - This file is only for from-scratch installations
911
-
912
- Args:
913
- patch_list: List of patch IDs to process
914
- version: Version string (e.g., "0.17.0")
915
-
916
- Returns:
917
- Path to generated file (model/data-X.Y.Z.sql), or None if no data files found
918
-
919
- Examples:
920
- self._generate_data_sql_file(
921
- ["456-auth", "457-roles"],
922
- "0.17.0"
923
- )
924
- # Generates model/data-0.17.0.sql with data from both patches
925
- """
926
- if not patch_list:
927
- return None
928
-
929
- try:
930
- # Collect all data files from patches
931
- data_files = self._repo.patch_manager._collect_data_files_from_patches(patch_list)
932
-
933
- if not data_files:
934
- # No data files found - skip generation
935
- return None
936
-
937
- # Generate output file in model/ directory
938
- output_filename = f"data-{version}.sql"
939
- output_path = Path(self._repo.model_dir) / output_filename
940
-
941
- with output_path.open('w', encoding='utf-8') as out_file:
942
- # Write header
943
- out_file.write(f"-- Data file for version {version}\n")
944
- out_file.write(f"-- Generated from patches: {', '.join(patch_list)}\n")
945
- out_file.write(f"-- This file contains reference data (DML) for from-scratch installations\n")
946
- out_file.write(f"--\n")
947
- out_file.write(f"-- Usage: Automatically loaded by restore_database_from_schema()\n")
948
- out_file.write(f"--\n\n")
949
-
950
- # Concatenate all data files
951
- for data_file in data_files:
952
- # Write separator comment
953
- out_file.write(f"-- ========================================\n")
954
- out_file.write(f"-- Source: {data_file}\n")
955
- out_file.write(f"-- ========================================\n\n")
956
-
957
- # Write file content (skip first line which is -- @HOP:data)
958
- content = data_file.read_text(encoding='utf-8')
959
- lines = content.split('\n')
960
-
961
- # Skip first line if it's the annotation
962
- if lines and lines[0].strip() == "-- @HOP:data":
963
- lines = lines[1:]
964
-
965
- out_file.write('\n'.join(lines))
966
- out_file.write('\n\n')
967
-
968
- return output_path
969
-
970
- except Exception as e:
971
- raise ReleaseManagerError(
972
- f"Failed to generate data SQL file data-{version}.sql: {e}"
973
- )
974
-
975
905
  def get_all_release_context_patches(self) -> List[str]:
976
906
  """
977
907
  Get all validated patches for the next release context.
@@ -2569,12 +2499,14 @@ class ReleaseManager:
2569
2499
  self._repo.hgit.checkout("-b", temp_branch)
2570
2500
 
2571
2501
  # 6. Apply patches to database (validation)
2572
- self._apply_release_patches(version, force_apply=is_prod)
2573
-
2574
- # 7. Execute bootstrap scripts (only for this version and earlier)
2575
- self._run_bootstrap_scripts(up_to_version=version)
2502
+ # Bootstrap SQL in patches is executed by apply_patch_files.
2503
+ # Bootstraps from bootstrap/ for OTHER versions run during restore.
2504
+ # Bootstraps for THIS version are excluded (already run via patches).
2505
+ self._apply_release_patches(
2506
+ version, force_apply=is_prod, exclude_bootstrap_version=version
2507
+ )
2576
2508
 
2577
- # 8. Register version in database
2509
+ # 7. Register version in database
2578
2510
  version_parts = version.split('.')
2579
2511
  major, minor, patch_num = map(int, version_parts)
2580
2512
  if is_prod:
@@ -2673,7 +2605,7 @@ class ReleaseManager:
2673
2605
 
2674
2606
  # Delete temporary branch if it exists
2675
2607
  try:
2676
- self._repo.hgit.delete_local_branch(temp_branch, force=True)
2608
+ self._repo.hgit.delete_local_branch(temp_branch)
2677
2609
  print(f" Deleted temporary branch {temp_branch}")
2678
2610
  except Exception:
2679
2611
  pass # Branch might not exist
@@ -2781,12 +2713,6 @@ class ReleaseManager:
2781
2713
  if release_schema_file.exists():
2782
2714
  release_schema_file.unlink()
2783
2715
 
2784
- # Generate data file
2785
- prod_patches = self.read_release_patches(prod_file.name)
2786
- data_file = self._generate_data_sql_file(prod_patches, version)
2787
- if data_file:
2788
- self._repo.hgit.add(str(data_file))
2789
-
2790
2716
  def _cleanup_release_branch(self, release_branch: str) -> list:
2791
2717
  """Delete release branch after production promotion."""
2792
2718
  deleted_branches = []
@@ -3226,13 +3152,6 @@ class ReleaseManager:
3226
3152
  if toml_file.exists():
3227
3153
  self._repo.hgit.rm(str(toml_file))
3228
3154
 
3229
- # Regenerate model/data-X.Y.Z.sql with all patches (original release + all hotfixes)
3230
- # This ensures from-scratch installations get all data
3231
- all_patches = self._collect_all_version_patches(version)
3232
- data_file = self._generate_data_sql_file(all_patches, version)
3233
- if data_file:
3234
- self._repo.hgit.add(str(data_file))
3235
-
3236
3155
  # 6. Apply release patches and generate SQL dumps
3237
3156
  self._apply_release_patches(version, True)
3238
3157
 
@@ -652,6 +652,24 @@ class Repo:
652
652
  # Checkout to target branch
653
653
  self.hgit.checkout(branch)
654
654
 
655
+ # Reset to origin (source of truth) before syncing .hop/
656
+ # This avoids non-fast-forward push failures when another
657
+ # actor has already synced this branch.
658
+ remote_ref = f"origin/{branch}"
659
+ try:
660
+ synced, status = self.hgit.compare_with_remote(branch)
661
+ if not synced and status == "diverged":
662
+ print(
663
+ f"Warning: branch {branch} has diverged from origin. "
664
+ f"Resetting to origin (source of truth).",
665
+ file=sys.stderr
666
+ )
667
+ if not synced:
668
+ self.hgit._HGit__git_repo.git.reset('--hard', remote_ref)
669
+ except Exception:
670
+ # Remote branch may not exist yet, continue without reset
671
+ pass
672
+
655
673
  # Reload config for this branch
656
674
  self.__config = Config(self.base_dir)
657
675
 
@@ -2304,7 +2322,7 @@ Each script is executed only once unless `--force` is used.
2304
2322
  self.model.execute_query('CREATE SCHEMA public')
2305
2323
  self.model.execute_query('GRANT ALL ON SCHEMA public TO public')
2306
2324
 
2307
- def restore_database_from_schema(self, exclude_bootstrap_patch_id: Optional[str] = None) -> None:
2325
+ def restore_database_from_schema(self, exclude_bootstrap_patch_id: Optional[str] = None, exclude_bootstrap_version: Optional[str] = None) -> None:
2308
2326
  """
2309
2327
  Restore database from model/schema.sql, metadata, and data files.
2310
2328
 
@@ -2417,13 +2435,14 @@ Each script is executed only once unless `--force` is used.
2417
2435
  self.model.reconnect(reload=True)
2418
2436
 
2419
2437
  # 7. Execute bootstrap scripts
2420
- # Note: In patch apply context, we want ALL bootstraps to run
2421
- # (staged patches + prod). Only the current patch is excluded.
2422
- # Version filtering is done separately in promote to prod/rc.
2438
+ # In patch apply context: ALL bootstraps run (only current patch excluded).
2439
+ # In promote context: exclude_bootstrap_version skips the version being
2440
+ # promoted (its bootstraps run after patches are applied).
2423
2441
  from half_orm_dev.bootstrap_manager import BootstrapManager
2424
2442
  bootstrap_mgr = BootstrapManager(self)
2425
2443
  bootstrap_mgr.run_bootstrap(
2426
- exclude_patch_id=exclude_bootstrap_patch_id
2444
+ exclude_patch_id=exclude_bootstrap_patch_id,
2445
+ exclude_version=exclude_bootstrap_version
2427
2446
  )
2428
2447
 
2429
2448
  except RepoError:
@@ -2563,7 +2582,8 @@ Each script is executed only once unless `--force` is used.
2563
2582
  def restore_database_from_release_schema(
2564
2583
  self,
2565
2584
  version: str,
2566
- exclude_bootstrap_patch_id: Optional[str] = None
2585
+ exclude_bootstrap_patch_id: Optional[str] = None,
2586
+ exclude_bootstrap_version: Optional[str] = None
2567
2587
  ) -> None:
2568
2588
  """
2569
2589
  Restore database from release schema file.
@@ -2578,6 +2598,8 @@ Each script is executed only once unless `--force` is used.
2578
2598
  version: Release version string (e.g., "0.17.1")
2579
2599
  exclude_bootstrap_patch_id: If provided, skip bootstrap files
2580
2600
  belonging to this patch (used during patch apply)
2601
+ exclude_bootstrap_version: If provided, skip bootstrap files for
2602
+ this version (used during promote)
2581
2603
 
2582
2604
  Raises:
2583
2605
  RepoError: If restoration fails
@@ -2591,7 +2613,10 @@ Each script is executed only once unless `--force` is used.
2591
2613
 
2592
2614
  # Fallback to production schema if release schema doesn't exist
2593
2615
  if not release_schema_path.exists():
2594
- self.restore_database_from_schema(exclude_bootstrap_patch_id)
2616
+ self.restore_database_from_schema(
2617
+ exclude_bootstrap_patch_id,
2618
+ exclude_bootstrap_version=exclude_bootstrap_version
2619
+ )
2595
2620
  return
2596
2621
 
2597
2622
  try:
@@ -2609,7 +2634,10 @@ Each script is executed only once unless `--force` is used.
2609
2634
  # Execute bootstrap scripts
2610
2635
  from half_orm_dev.bootstrap_manager import BootstrapManager
2611
2636
  bootstrap_mgr = BootstrapManager(self)
2612
- bootstrap_mgr.run_bootstrap(exclude_patch_id=exclude_bootstrap_patch_id)
2637
+ bootstrap_mgr.run_bootstrap(
2638
+ exclude_patch_id=exclude_bootstrap_patch_id,
2639
+ exclude_version=exclude_bootstrap_version
2640
+ )
2613
2641
 
2614
2642
  except Exception as e:
2615
2643
  raise RepoError(f"Failed to restore from release schema: {e}") from e
@@ -0,0 +1 @@
1
+ 0.17.5-a5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 0.17.5a3
3
+ Version: 0.17.5a5
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
@@ -1 +0,0 @@
1
- 0.17.5-a3
File without changes
File without changes