half-orm-dev 1.0.0a26__tar.gz → 1.0.0a27__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 (82) hide show
  1. {half_orm_dev-1.0.0a26/half_orm_dev.egg-info → half_orm_dev-1.0.0a27}/PKG-INFO +1 -1
  2. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/bootstrap_manager.py +11 -6
  3. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/migrate.py +1 -1
  4. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/set_git_origin.py +1 -1
  5. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/main.py +2 -2
  6. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/decorators.py +6 -4
  7. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/hgit.py +89 -0
  8. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migration_manager.py +57 -164
  9. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +1 -1
  10. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +1 -1
  11. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/release_manager.py +206 -335
  12. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/repo.py +33 -101
  13. half_orm_dev-1.0.0a27/half_orm_dev/version.txt +1 -0
  14. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27/half_orm_dev.egg-info}/PKG-INFO +1 -1
  15. half_orm_dev-1.0.0a26/half_orm_dev/version.txt +0 -1
  16. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/AUTHORS +0 -0
  17. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/LICENSE +0 -0
  18. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/README.md +0 -0
  19. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/__init__.py +0 -0
  20. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/__init__.py +0 -0
  21. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/__init__.py +0 -0
  22. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/apply.py +0 -0
  23. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/bootstrap.py +0 -0
  24. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/check.py +0 -0
  25. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/clone.py +0 -0
  26. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/init.py +0 -0
  27. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/patch.py +0 -0
  28. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/release.py +0 -0
  29. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/restore.py +0 -0
  30. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/revert_migration.py +0 -0
  31. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/sync.py +0 -0
  32. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/todo.py +0 -0
  33. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/undo.py +0 -0
  34. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/update.py +0 -0
  35. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli/commands/upgrade.py +0 -0
  36. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/cli_extension.py +0 -0
  37. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/database.py +0 -0
  38. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/file_executor.py +0 -0
  39. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  40. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  41. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
  42. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
  43. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
  44. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
  45. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/1/0/0/a20/01_update_gitignore.py +0 -0
  46. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -0
  47. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/modules.py +0 -0
  48. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/patch_manager.py +0 -0
  49. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/patch_validator.py +0 -0
  50. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  51. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  52. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  53. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/patches/log +0 -0
  54. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  55. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/py.typed +0 -0
  56. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/release_file.py +0 -0
  57. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/scripts/repair-metadata.py +0 -0
  58. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/.gitignore +0 -0
  59. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/MANIFEST.in +0 -0
  60. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/README +0 -0
  61. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/conftest_template +0 -0
  62. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  63. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/git-hooks/pre-push +0 -0
  64. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  65. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/git-hooks/reference-transaction +0 -0
  66. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/init_module_template +0 -0
  67. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/module_template_1 +0 -0
  68. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/module_template_2 +0 -0
  69. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/module_template_3 +0 -0
  70. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/pyproject.toml +0 -0
  71. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/relation_test +0 -0
  72. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/sql_adapter +0 -0
  73. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/templates/warning +0 -0
  74. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev/utils.py +0 -0
  75. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev.egg-info/SOURCES.txt +0 -0
  76. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  77. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev.egg-info/entry_points.txt +0 -0
  78. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev.egg-info/requires.txt +0 -0
  79. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/half_orm_dev.egg-info/top_level.txt +0 -0
  80. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/pyproject.toml +0 -0
  81. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/setup.cfg +0 -0
  82. {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a27}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 1.0.0a26
3
+ Version: 1.0.0a27
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
@@ -253,14 +253,14 @@ class BootstrapManager:
253
253
  'errors': []
254
254
  }
255
255
 
256
+ executed_set = self.get_executed_files()
257
+ all_files = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
258
+
256
259
  if force:
257
- files_to_execute = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
260
+ files_to_execute = all_files
258
261
  else:
259
- files_to_execute = self.get_pending_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
260
- # Calculate skipped
261
- all_files = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
262
- executed = self.get_executed_files()
263
- result['skipped'] = [f.name for f in all_files if f.name in executed]
262
+ files_to_execute = [f for f in all_files if f.name not in executed_set]
263
+ result['skipped'] = [f.name for f in all_files if f.name in executed_set]
264
264
 
265
265
  if not files_to_execute:
266
266
  return result
@@ -278,10 +278,15 @@ class BootstrapManager:
278
278
  result['executed'].append(filename)
279
279
  continue
280
280
 
281
+ if filename in executed_set:
282
+ result['skipped'].append(filename)
283
+ continue
284
+
281
285
  try:
282
286
  click.echo(f" • Executing {filename}...")
283
287
  self.execute_file(file_path)
284
288
  self.record_execution(filename, file_version)
289
+ executed_set.add(filename)
285
290
  result['executed'].append(filename)
286
291
  except BootstrapManagerError as e:
287
292
  result['errors'].append((filename, str(e)))
@@ -65,7 +65,7 @@ def migrate(verbose: bool) -> None:
65
65
  # Get current versions
66
66
  from half_orm_dev.utils import hop_version
67
67
  installed_version = hop_version()
68
- config_version = repo._Repo__config.hop_version if hasattr(repo, '_Repo__config') else '0.0.0'
68
+ config_version = repo.config.hop_version
69
69
 
70
70
  # Migration needed (comparison > 0)
71
71
  click.echo(f"⚠️ {utils.Color.bold('Migration needed:')}")
@@ -37,7 +37,7 @@ def set_git_origin(new_origin):
37
37
 
38
38
  # 1. Update .hop/config
39
39
  repo.git_origin = new_origin
40
- repo._Repo__config.write()
40
+ repo.config.write()
41
41
  click.echo(f" ✓ .hop/config updated")
42
42
 
43
43
  # 2. Update pyproject.toml Homepage
@@ -141,7 +141,7 @@ def create_cli_group():
141
141
  if hop.repo_checked and hop._Hop__repo.needs_migration():
142
142
  from half_orm_dev.utils import hop_version
143
143
  installed_version = hop_version()
144
- config_version = hop._Hop__repo._Repo__config.hop_version
144
+ config_version = hop._Hop__repo.repo.config.hop_version
145
145
 
146
146
  @click.command(
147
147
  cmd_name,
@@ -198,7 +198,7 @@ def create_cli_group():
198
198
  from half_orm_dev.utils import hop_version
199
199
  from half_orm_dev.repo import RepoError
200
200
  installed_version = hop_version()
201
- config_version = hop._Hop__repo._Repo__config.hop_version
201
+ config_version = hop._Hop__repo.repo.config.hop_version
202
202
  current_branch = hop._Hop__repo.hgit.branch if hop._Hop__repo.hgit else 'unknown'
203
203
 
204
204
  click.echo(f"\n{'='*70}")
@@ -39,6 +39,8 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
39
39
  def decorator(func):
40
40
  @wraps(func)
41
41
  def wrapper(self, *args, **kwargs):
42
+ # Support both Manager classes (self._repo) and Repo itself (self)
43
+ repo = getattr(self, '_repo', self)
42
44
  lock_tag = None
43
45
  locked_branch = None
44
46
  try:
@@ -48,20 +50,20 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
48
50
  # 2. All branches are fetched (prune)
49
51
  # 3. Repository hop_version is validated against installed version
50
52
  # 4. Operation is blocked if version is outdated (prevents dangerous operations)
51
- self._repo.sync_and_validate_ho_prod()
53
+ repo.sync_and_validate_ho_prod()
52
54
 
53
55
  # Determine branch name dynamically
54
56
  locked_branch = branch_getter(self, *args, **kwargs)
55
57
 
56
58
  # Acquire lock
57
- lock_tag = self._repo.hgit.acquire_branch_lock(locked_branch, timeout_minutes=timeout_minutes)
59
+ lock_tag = repo.hgit.acquire_branch_lock(locked_branch, timeout_minutes=timeout_minutes)
58
60
 
59
61
  # Execute the method
60
62
  result = func(self, *args, **kwargs)
61
63
 
62
64
  # After success, sync .hop/ from current branch to all other active branches
63
65
  try:
64
- sync_result = self._repo.sync_hop_to_active_branches(
66
+ sync_result = repo.sync_hop_to_active_branches(
65
67
  reason=f"{func.__name__}"
66
68
  )
67
69
  # Log sync errors but don't fail the operation
@@ -84,7 +86,7 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
84
86
  wrapper, '_interrupted', True) or None)
85
87
  wrapper._interrupted = False
86
88
  try:
87
- self._repo.hgit.release_branch_lock(lock_tag)
89
+ repo.hgit.release_branch_lock(lock_tag)
88
90
  finally:
89
91
  interrupted = wrapper._interrupted
90
92
  signal.signal(signal.SIGINT, original_handler)
@@ -7,6 +7,7 @@ import sys
7
7
  import subprocess
8
8
  import fnmatch
9
9
  import git
10
+ from contextlib import contextmanager
10
11
  from git.exc import GitCommandError
11
12
  from typing import List, Optional
12
13
  import time
@@ -23,6 +24,7 @@ class HGit:
23
24
  self.__repo = repo
24
25
  self.__base_dir = None
25
26
  self.__git_repo: git.Repo = None
27
+ self.__snapshot: dict = {}
26
28
  if repo:
27
29
  self.__origin = repo.git_origin
28
30
  self.__base_dir = repo.base_dir
@@ -78,6 +80,7 @@ class HGit:
78
80
  )
79
81
 
80
82
  self.__current_branch = self.branch
83
+ self.__snapshot = self.capture_branches_snapshot()
81
84
 
82
85
  def __str__(self):
83
86
  res = ['[Git]']
@@ -267,6 +270,92 @@ class HGit:
267
270
  "Proxy to git.commit method"
268
271
  return self.__git_repo.git.checkout(*args, **kwargs)
269
272
 
273
+ @contextmanager
274
+ def on_branch(self, branch: str, silent: bool = False):
275
+ """Temporarily switch to branch, yield, then return to original branch."""
276
+ original = self.branch
277
+ switched = branch != original
278
+ if switched:
279
+ if not silent:
280
+ print(f" Switching to {branch}...")
281
+ self.__git_repo.git.checkout(branch)
282
+ if not silent:
283
+ print(f" ✓ Now on {branch}")
284
+ try:
285
+ yield
286
+ finally:
287
+ if switched:
288
+ try:
289
+ if not silent:
290
+ print(f" Returning to {original}...")
291
+ self.__git_repo.git.checkout(original)
292
+ if not silent:
293
+ print(f" ✓ Back on {original}")
294
+ except Exception as e:
295
+ if not silent:
296
+ print(f" ⚠ Could not return to {original}: {e}", file=sys.stderr)
297
+ print(f" You are now on {branch}", file=sys.stderr)
298
+
299
+ @property
300
+ def snapshot(self) -> dict:
301
+ """Return the last stored branches snapshot."""
302
+ return self.__snapshot
303
+
304
+ def update_snapshot(self) -> None:
305
+ """Refresh the stored snapshot to reflect current branch HEADs."""
306
+ self.__snapshot = self.capture_branches_snapshot()
307
+
308
+ def capture_branches_snapshot(self) -> dict:
309
+ """Return {branch_name: HEAD_SHA} for all active local branches."""
310
+ snapshot = {}
311
+ try:
312
+ branches_status = self.get_active_branches_status()
313
+ except Exception:
314
+ return snapshot
315
+
316
+ candidates = []
317
+ prod = branches_status.get('prod_branch')
318
+ if prod:
319
+ candidates.append(prod['name'])
320
+ for b in branches_status.get('release_branches', []):
321
+ if b.get('exists_on_remote', True):
322
+ candidates.append(b['name'])
323
+ for b in branches_status.get('patch_branches', []):
324
+ if b.get('exists_on_remote', True):
325
+ candidates.append(b['name'])
326
+
327
+ for name in candidates:
328
+ try:
329
+ snapshot[name] = self.__git_repo.heads[name].commit.hexsha
330
+ except Exception:
331
+ pass
332
+ return snapshot
333
+
334
+ def rollback_to_snapshot(self, snapshot: dict = None) -> dict:
335
+ """Reset each branch to its captured SHA.
336
+
337
+ Uses the stored snapshot when none is provided.
338
+ Returns {'reset': [branch, ...], 'errors': [(branch, msg), ...]}.
339
+ """
340
+ target = snapshot if snapshot is not None else self.__snapshot
341
+ result = {'reset': [], 'errors': []}
342
+ original = self.branch
343
+
344
+ for branch, sha in target.items():
345
+ try:
346
+ self.__git_repo.heads[branch].checkout()
347
+ self.__git_repo.git.reset('--hard', sha)
348
+ result['reset'].append(branch)
349
+ except Exception as e:
350
+ result['errors'].append((branch, str(e)))
351
+
352
+ try:
353
+ self.__git_repo.heads[original].checkout()
354
+ except Exception as e:
355
+ result['errors'].append(('__restore_original__', str(e)))
356
+
357
+ return result
358
+
270
359
  def checkout_paths_from_branch(self, branch: str, paths: list) -> None:
271
360
  """
272
361
  Checkout specific paths from another branch into working directory.
@@ -254,26 +254,31 @@ class MigrationManager:
254
254
  result['sync_files'] = list(dict.fromkeys(result['sync_files']))
255
255
  return result
256
256
 
257
- @with_dynamic_branch_lock(lambda self, *args, **kwargs: 'ho-prod')
258
257
  def run_migrations(self, target_version: str, create_commit: bool = True) -> Dict:
259
258
  """
260
- Run all pending migrations up to target version.
261
-
262
- IMPORTANT: This method acquires a lock on ho-prod branch via decorator.
263
- Should only be called when on ho-prod branch.
259
+ Run all pending migrations from the current repo version up to target_version.
264
260
 
265
- After successful completion, the decorator automatically syncs .hop/
266
- directory to all active branches (ho-patch/*, ho-release/*).
261
+ Must be called via Repo.run_migrations_if_needed() (enforced by guard).
262
+ Must be called while on the ho-prod branch.
267
263
 
268
264
  Args:
269
- target_version: Target version string (e.g., "0.17.1")
270
- create_commit: Whether to create Git commit after migration
265
+ target_version: Target version string (e.g., "1.0.0-a26")
266
+ create_commit: Whether to create a Git commit after migration
271
267
 
272
268
  Returns:
273
- Dict with migration results including:
274
- - migrations_applied: List of applied migrations
275
- - commit_created: Whether migration commit was created
269
+ Dict with keys:
270
+ - target_version: str
271
+ - migrations_applied: List of per-migration result dicts
272
+ - commit_created: bool
273
+ - commit_message: str (only when commit_created is True)
274
+ - sync_result: dict from commit_and_sync_to_active_branches
275
+ - errors: List of non-fatal warning strings
276
276
  """
277
+ if not getattr(self._repo, '_migration_running', False):
278
+ raise MigrationManagerError(
279
+ "run_migrations() must be called via Repo.run_migrations_if_needed()."
280
+ )
281
+
277
282
  result = {
278
283
  'target_version': target_version,
279
284
  'migrations_applied': [],
@@ -282,165 +287,56 @@ class MigrationManager:
282
287
  'notified_branches': []
283
288
  }
284
289
 
285
- # Fetch from origin to ensure we have latest refs
286
- try:
287
- self._repo.hgit.fetch_from_origin()
288
- except Exception as e:
289
- result['errors'].append(f"Failed to fetch from origin: {e}")
290
- raise MigrationManagerError(f"Cannot run migration: failed to fetch from origin: {e}")
291
-
292
- # Verify ho-prod is up to date with origin/ho-prod
293
- current_branch = self._repo.hgit.branch
294
- if current_branch == 'ho-prod':
295
- try:
296
- # Check if ho-prod is synced with origin/ho-prod
297
- result_check = subprocess.run(
298
- ['git', 'rev-list', '--left-right', '--count', 'ho-prod...origin/ho-prod'],
299
- cwd=self._repo.base_dir,
300
- capture_output=True,
301
- text=True,
302
- check=True
303
- )
304
- ahead, behind = map(int, result_check.stdout.strip().split())
305
- if behind > 0:
306
- # Check whether the migration was already applied on origin/ho-prod.
307
- already_done = False
308
- try:
309
- remote_config_text = self._repo.hgit._HGit__git_repo.git.show(
310
- 'origin/ho-prod:.hop/config'
311
- )
312
- _cp = ConfigParser()
313
- _cp.read_string(remote_config_text)
314
- remote_version = _cp['halfORM'].get('hop_version', '')
315
- if remote_version == target_version:
316
- already_done = True
317
- except Exception:
318
- pass
319
-
320
- if already_done:
321
- # Another developer already ran the migration.
322
- # Pull and sync all active branches, then return.
323
- click.echo(
324
- f" ℹ Migration to {target_version} already applied by another developer.\n"
325
- f" Pulling and syncing all active branches..."
326
- )
327
- self._repo.hgit._HGit__git_repo.remotes.origin.pull('ho-prod')
328
- self._repo.hgit.sync_active_branches(pattern="ho-*")
329
- # Reload config so the rest of the tool sees the new version
330
- self._repo._Repo__config.read()
331
- result['already_synced'] = True
332
- return result
333
-
334
- raise MigrationManagerError(
335
- f"ho-prod is {behind} commits behind origin/ho-prod. "
336
- f"Please pull changes first: git pull origin ho-prod"
337
- )
338
- if ahead > 0:
339
- result['errors'].append(f"Warning: ho-prod is {ahead} commits ahead of origin/ho-prod")
340
- except (MigrationManagerError, subprocess.CalledProcessError):
341
- raise
342
- except Exception:
343
- # Could not compare — maybe origin/ho-prod doesn't exist yet
344
- pass
290
+ current_version = self._repo.config.hop_version
345
291
 
346
- # Get current version from .hop/config
347
- current_version = self._repo._Repo__config.hop_version if hasattr(
348
- self._repo, '_Repo__config'
349
- ) else "0.0.0"
350
-
351
- # If already at target version, nothing to do
352
- try:
353
- comparison = self._repo.compare_versions(current_version, target_version)
354
-
355
- if comparison >= 0:
356
- # Already at or past target version (0 = equal, 1 = higher)
357
- return result
358
- except Exception as e:
359
- # If version comparison fails (invalid format), log and continue
360
- # This allows migration to proceed even if version format is unexpected
361
- result['errors'].append(
362
- f"Could not compare versions {current_version} and {target_version}: {e}. "
363
- f"Continuing with migration attempt."
364
- )
292
+ comparison = self._repo.compare_versions(current_version, target_version)
293
+ if comparison >= 0:
294
+ return result
365
295
 
366
- # Ensure all active branches are in sync with origin before touching anything
367
- self._ensure_active_branches_synced()
368
-
369
- # Get pending migrations
370
296
  pending = self.get_pending_migrations(current_version, target_version)
371
297
 
372
- # Apply each migration if there are any
373
- if pending:
298
+ try:
374
299
  for version_str, migration_dir in pending:
375
- try:
376
- migration_result = self.apply_migration(version_str, migration_dir)
377
- result['migrations_applied'].append(migration_result)
300
+ migration_result = self.apply_migration(version_str, migration_dir)
301
+ result['migrations_applied'].append(migration_result)
378
302
 
379
- except MigrationManagerError as e:
380
- result['errors'].append(str(e))
381
- raise
303
+ # hop_version setter calls write() internally — one write only
304
+ self._repo.config.hop_version = target_version
382
305
 
383
- # Update hop_version in .hop/config (current_version != target_version)
384
- # This ensures the version is updated even when upgrading between versions
385
- # that have no migration scripts (e.g., 0.17.1-a2 → 0.17.2-a3)
386
- if hasattr(self._repo, '_Repo__config'):
387
- self._repo._Repo__config.hop_version = target_version
388
- self._repo._Repo__config.write()
389
-
390
- # Update half_orm_dev version in pyproject.toml
391
- self._update_pyproject_dependency_version(target_version)
306
+ self._update_pyproject_dependency_version(target_version)
307
+ except Exception:
308
+ self._repo.hgit.rollback_to_snapshot()
309
+ raise
392
310
 
393
- # Collect all sync_files from migrations + pyproject.toml
394
- all_sync_files = ['pyproject.toml'] # Always sync pyproject.toml
311
+ all_sync_files = ['pyproject.toml']
395
312
  for migration in result['migrations_applied']:
396
313
  all_sync_files.extend(migration.get('sync_files', []))
397
- # Remove duplicates while preserving order
398
314
  all_sync_files = list(dict.fromkeys(all_sync_files))
399
315
 
400
- # Create Git commit if requested
401
316
  if create_commit and self._repo.hgit:
402
- try:
403
- commit_msg = self._create_migration_commit_message(
404
- current_version,
405
- target_version,
406
- result['migrations_applied']
407
- )
408
-
409
- # Commit and sync to active branches (including migration files)
410
- sync_result = self._repo.commit_and_sync_to_active_branches(
411
- message=commit_msg,
412
- reason=f"migration {current_version} → {target_version}",
413
- files=all_sync_files
414
- )
415
-
416
- result['commit_created'] = True
417
- result['commit_message'] = commit_msg
418
- result['commit_pushed'] = True
419
- result['sync_result'] = sync_result
317
+ commit_msg = self._create_migration_commit_message(
318
+ current_version, target_version, result['migrations_applied']
319
+ )
420
320
 
421
- # Create annotated tag encoding all commit SHAs for potential revert
422
- self._create_migration_tag(
423
- current_version, target_version, sync_result
424
- )
321
+ sync_result = self._repo.commit_and_sync_to_active_branches(
322
+ message=commit_msg,
323
+ reason=f"migration {current_version} → {target_version}",
324
+ files=all_sync_files
325
+ )
425
326
 
426
- # Pseudo-patch: regenerate modules and sync to active branches
427
- try:
428
- self._regenerate_modules_after_migration(
429
- current_version, target_version
430
- )
431
- except Exception as regen_err:
432
- result['errors'].append(
433
- f"Module regeneration after migration failed: {regen_err}"
434
- )
327
+ result['commit_created'] = True
328
+ result['commit_message'] = commit_msg
329
+ result['sync_result'] = sync_result
435
330
 
331
+ try:
332
+ self._create_migration_tag(current_version, target_version, sync_result)
436
333
  except Exception as e:
437
- # Don't fail migration if commit fails
438
- result['errors'].append(f"Failed to create commit: {e}")
334
+ result['errors'].append(f"Migration tag creation failed: {e}")
439
335
 
440
- # Note: Branch synchronization is now handled automatically by the
441
- # @with_dynamic_branch_lock decorator when the method completes.
442
- # The decorator calls repo.sync_hop_to_active_branches() for all
443
- # operations on ho-prod, ensuring .hop/ is always synced.
336
+ try:
337
+ self._regenerate_modules_after_migration(current_version, target_version)
338
+ except Exception as e:
339
+ result['errors'].append(f"Module regeneration after migration failed: {e}")
444
340
 
445
341
  return result
446
342
 
@@ -624,12 +520,11 @@ class MigrationManager:
624
520
  b['name'] for b in branches_status.get('release_branches', [])
625
521
  if b.get('exists_on_remote', True)
626
522
  ]
627
- # ho-patch/* branches are excluded: their schema includes tables that
628
- # don't yet exist in production, so restoring the production schema
629
- # before generate() would delete the modules for those new tables and
630
- # erase user-written code (Fkeys, business methods). The developer
631
- # will re-run `hop patch apply` which regenerates with the correct schema.
632
- all_branches = ['ho-prod'] + release_branches
523
+ # ho-patch/* branches receive .hop/ and pyproject.toml updates but skip
524
+ # module regeneration: their schema may include tables not yet in
525
+ # production, so generate() would delete modules for those new tables.
526
+ # The developer re-runs `hop patch apply` to regenerate with the correct schema.
527
+ all_branches = ['ho-prod'] + release_branches + patch_branches
633
528
 
634
529
  for branch in all_branches:
635
530
  try:
@@ -651,7 +546,8 @@ class MigrationManager:
651
546
  # ho-prod and ho-patch/*: use production schema
652
547
  repo.restore_database_from_schema(skip_bootstrap=True)
653
548
 
654
- _modules.generate(repo)
549
+ if not branch in patch_branches:
550
+ _modules.generate(repo)
655
551
  repo.hgit.add(package_dir)
656
552
  if not git_repo.git.diff('--cached', '--name-only').strip():
657
553
  continue
@@ -796,10 +692,7 @@ class MigrationManager:
796
692
  Returns:
797
693
  True if migration/update is needed
798
694
  """
799
- if not hasattr(self._repo, '_Repo__config'):
800
- return False
801
-
802
- config_version = self._repo._Repo__config.hop_version
695
+ config_version = self._repo.config.hop_version
803
696
 
804
697
  # If no hop_version is configured, no migration needed
805
698
  if not config_version:
@@ -55,7 +55,7 @@ def migrate(repo):
55
55
  print(f" pyproject.toml not found.")
56
56
  if click.confirm(" Create pyproject.toml from template?", default=True):
57
57
  try:
58
- package_name = repo._Repo__config.package_name
58
+ package_name = repo.config.package_name
59
59
  template_path = os.path.join(TEMPLATE_DIRS, 'pyproject.toml')
60
60
  template = utils.read(template_path)
61
61
 
@@ -59,7 +59,7 @@ def migrate(repo):
59
59
  import click
60
60
 
61
61
  base_dir = Path(repo.base_dir)
62
- package_name = repo._Repo__config.package_name
62
+ package_name = repo.config.package_name
63
63
 
64
64
  _migrate_init(repo, base_dir, package_name, click)
65
65
  _migrate_conftest(repo, base_dir, package_name, click)