half-orm-dev 1.0.0a29__tar.gz → 1.0.0a31__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 (84) hide show
  1. {half_orm_dev-1.0.0a29/half_orm_dev.egg-info → half_orm_dev-1.0.0a31}/PKG-INFO +1 -1
  2. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/bootstrap_manager.py +6 -2
  3. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/__init__.py +4 -3
  4. half_orm_dev-1.0.0a31/half_orm_dev/cli/commands/recover.py +46 -0
  5. half_orm_dev-1.0.0a31/half_orm_dev/cli/commands/rollback.py +103 -0
  6. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/main.py +2 -2
  7. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/database.py +23 -0
  8. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/decorators.py +43 -11
  9. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/file_executor.py +68 -0
  10. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/release_manager.py +253 -160
  11. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/repo.py +155 -0
  12. half_orm_dev-1.0.0a31/half_orm_dev/version.txt +1 -0
  13. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31/half_orm_dev.egg-info}/PKG-INFO +1 -1
  14. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/SOURCES.txt +2 -1
  15. half_orm_dev-1.0.0a29/half_orm_dev/cli/commands/update.py +0 -73
  16. half_orm_dev-1.0.0a29/half_orm_dev/version.txt +0 -1
  17. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/AUTHORS +0 -0
  18. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/LICENSE +0 -0
  19. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/README.md +0 -0
  20. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/__init__.py +0 -0
  21. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/__init__.py +0 -0
  22. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/apply.py +0 -0
  23. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/bootstrap.py +0 -0
  24. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/check.py +0 -0
  25. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/clone.py +0 -0
  26. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/init.py +0 -0
  27. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/migrate.py +0 -0
  28. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/patch.py +0 -0
  29. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/release.py +0 -0
  30. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/restore.py +0 -0
  31. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/revert_migration.py +0 -0
  32. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
  33. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/sync.py +0 -0
  34. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/todo.py +0 -0
  35. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/undo.py +0 -0
  36. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/upgrade.py +0 -0
  37. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli_extension.py +0 -0
  38. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/hgit.py +0 -0
  39. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migration_manager.py +0 -0
  40. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  41. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  42. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
  43. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
  44. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
  45. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
  46. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +0 -0
  47. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
  48. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/1/0/0/a20/01_update_gitignore.py +0 -0
  49. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -0
  50. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/modules.py +0 -0
  51. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patch_manager.py +0 -0
  52. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patch_validator.py +0 -0
  53. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  54. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  55. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  56. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/log +0 -0
  57. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  58. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/py.typed +0 -0
  59. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/release_file.py +0 -0
  60. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/scripts/repair-metadata.py +0 -0
  61. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/.gitignore +0 -0
  62. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/MANIFEST.in +0 -0
  63. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/README +0 -0
  64. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/conftest_template +0 -0
  65. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  66. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/git-hooks/pre-push +0 -0
  67. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  68. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/git-hooks/reference-transaction +0 -0
  69. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/init_module_template +0 -0
  70. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/module_template_1 +0 -0
  71. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/module_template_2 +0 -0
  72. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/module_template_3 +0 -0
  73. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/pyproject.toml +0 -0
  74. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/relation_test +0 -0
  75. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/sql_adapter +0 -0
  76. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/warning +0 -0
  77. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/utils.py +0 -0
  78. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  79. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/entry_points.txt +0 -0
  80. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/requires.txt +0 -0
  81. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/top_level.txt +0 -0
  82. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/pyproject.toml +0 -0
  83. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/setup.cfg +0 -0
  84. {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 1.0.0a29
3
+ Version: 1.0.0a31
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
@@ -18,7 +18,7 @@ from pathlib import Path
18
18
  from typing import List, Set, Tuple, Optional, TYPE_CHECKING
19
19
 
20
20
  from half_orm_dev.file_executor import (
21
- execute_sql_file, execute_python_file, FileExecutionError
21
+ execute_sql_file, execute_python_bootstrap, FileExecutionError
22
22
  )
23
23
 
24
24
  if TYPE_CHECKING:
@@ -180,7 +180,11 @@ class BootstrapManager:
180
180
  if file_path.suffix == '.sql':
181
181
  execute_sql_file(file_path, self._repo.database.model)
182
182
  elif file_path.suffix == '.py':
183
- output = execute_python_file(file_path, cwd=self._bootstrap_dir)
183
+ output = execute_python_bootstrap(
184
+ file_path,
185
+ model=self._repo.database.model,
186
+ cwd=self._bootstrap_dir
187
+ )
184
188
  if output:
185
189
  click.echo(f" Output: {output}")
186
190
  else:
@@ -10,15 +10,15 @@ from .init import init
10
10
  from .clone import clone
11
11
  from .patch import patch
12
12
  from .release import release
13
- from .update import update
14
13
  from .upgrade import upgrade
15
14
  from .check import check
16
15
  from .set_git_origin import set_git_origin
17
16
  from .migrate import migrate
18
17
  from .revert_migration import revert_migration
19
18
  from .bootstrap import bootstrap
19
+ from .rollback import rollback
20
+ from .recover import recover
20
21
  from .todo import apply_release
21
- from .todo import rollback
22
22
 
23
23
  # ♻️ Adapted existing commands
24
24
  from .todo import sync_package # Unchanged
@@ -31,7 +31,6 @@ ALL_COMMANDS = {
31
31
  'clone': clone,
32
32
  'patch': patch,
33
33
  'release': release,
34
- 'update': update, # Adapted for production
35
34
  'upgrade': upgrade, # Adapted for production
36
35
  'check': check, # Project health check and updates
37
36
  'set-git-origin': set_git_origin, # Update git remote origin URL
@@ -43,6 +42,7 @@ ALL_COMMANDS = {
43
42
 
44
43
  # 🚧 Emergency workflow (stubs)
45
44
  'rollback': rollback,
45
+ 'recover': recover,
46
46
 
47
47
  # ♻️ Adapted commands
48
48
  'sync-package': sync_package, # Unchanged
@@ -60,6 +60,7 @@ __all__ = [
60
60
  'migrate',
61
61
  'bootstrap',
62
62
  'rollback',
63
+ 'recover',
63
64
  # Adapted commands
64
65
  'sync_package',
65
66
  'ALL_COMMANDS'
@@ -0,0 +1,46 @@
1
+ """
2
+ Recover command - Complete or clean up an interrupted sync operation.
3
+ """
4
+
5
+ import sys
6
+ import click
7
+ from half_orm_dev.repo import Repo
8
+ from half_orm import utils
9
+
10
+
11
+ @click.command()
12
+ def recover() -> None:
13
+ """Complete or clean up a sync interrupted by a crash or network failure.
14
+
15
+ Reads .git/hop-sync-lock to verify ownership of the interrupted operation,
16
+ then either pushes branches whose sync commit completed (Phase 2 finish)
17
+ or restores clean state for branches interrupted mid-commit (Phase 1 cleanup).
18
+ Releases the distributed lock when done.
19
+ """
20
+ repo = Repo()
21
+ result = repo.recover()
22
+
23
+ if result['errors'] and not result['pushed_branches'] and not result['cleaned_branches']:
24
+ for error in result['errors']:
25
+ click.echo(utils.Color.red(f"Error: {error}"), err=True)
26
+ sys.exit(1)
27
+
28
+ if result['lock_tag']:
29
+ click.echo(f"Recovering from interrupted sync (lock: {result['lock_tag']})")
30
+
31
+ if result['pushed_branches']:
32
+ for branch in result['pushed_branches']:
33
+ click.echo(f" Pushed: {branch}")
34
+
35
+ if result['cleaned_branches']:
36
+ for branch in result['cleaned_branches']:
37
+ click.echo(f" Cleaned: {branch}")
38
+
39
+ if not result['pushed_branches'] and not result['cleaned_branches']:
40
+ click.echo("Nothing to recover — lock released.")
41
+
42
+ if result['errors']:
43
+ for error in result['errors']:
44
+ click.echo(utils.Color.yellow(f"Warning: {error}"), err=True)
45
+
46
+ click.echo(utils.Color.green("Recovery complete."))
@@ -0,0 +1,103 @@
1
+ """
2
+ Rollback command - Restore production to a previous version.
3
+
4
+ Restores the database from a stored snapshot and checks out the
5
+ corresponding immutable ho-prod-X.Y.Z branch.
6
+ """
7
+
8
+ import click
9
+ from half_orm_dev.repo import Repo
10
+ from half_orm_dev.release_manager import ReleaseManagerError
11
+ from half_orm import utils
12
+
13
+
14
+ @click.command()
15
+ @click.option(
16
+ '--to-version', '-t',
17
+ type=str,
18
+ default=None,
19
+ help='Target version (default: previous version)'
20
+ )
21
+ @click.option(
22
+ '--yes', '-y',
23
+ is_flag=True,
24
+ help='Skip confirmation prompt'
25
+ )
26
+ def rollback(to_version: str, yes: bool) -> None:
27
+ """
28
+ Rollback production to a previous version.
29
+
30
+ Restores the database from the snapshot created before the last upgrade
31
+ and checks out the corresponding ho-prod-X.Y.Z branch.
32
+
33
+ Requirements:
34
+ • Must be on a ho-prod-X.Y.Z branch
35
+ • Snapshot must exist for the target version
36
+
37
+ \b
38
+ Examples:
39
+ # Rollback to previous version (default)
40
+ half_orm dev rollback
41
+
42
+ # Rollback to a specific version
43
+ half_orm dev rollback --to-version 0.2.23
44
+ """
45
+ try:
46
+ repo = Repo()
47
+ mgr = repo.release_manager
48
+
49
+ current_version = repo.database.last_release_s
50
+ available = mgr._list_rollback_versions()
51
+
52
+ if not available:
53
+ click.echo(utils.Color.red("❌ No snapshots available for rollback."), err=True)
54
+ raise click.Abort()
55
+
56
+ # Resolve default target
57
+ target = to_version
58
+ if target is None:
59
+ from packaging.version import Version
60
+ candidates = [v for v in available if Version(v) < Version(current_version)]
61
+ target = max(candidates, key=lambda v: Version(v)) if candidates else None
62
+ if target is None:
63
+ click.echo(
64
+ utils.Color.red(f"❌ No previous version available for {current_version}."),
65
+ err=True
66
+ )
67
+ raise click.Abort()
68
+
69
+ # Display options
70
+ click.echo(f"Current version: {utils.Color.bold(current_version)}")
71
+ click.echo(f"\nAvailable rollback versions:")
72
+ for v in available:
73
+ marker = f" {utils.Color.bold('← default')}" if v == target else ""
74
+ click.echo(f" • {utils.Color.bold(v)}{marker}")
75
+
76
+ click.echo(f"\nWill rollback: {current_version} → {utils.Color.bold(target)}")
77
+ click.echo(f"⚠️ This will REPLACE the current database with snapshot ho-prod-{target}.")
78
+
79
+ if not yes:
80
+ if not click.confirm("\nProceed?", default=False):
81
+ click.echo("Cancelled.")
82
+ return
83
+
84
+ click.echo("\nRolling back...")
85
+ result = mgr.rollback_production(to_version=target)
86
+
87
+ click.echo(
88
+ f"\n✓ {utils.Color.green('Rollback complete:')} "
89
+ f"{result['from_version']} → {utils.Color.bold(result['to_version'])}"
90
+ )
91
+ click.echo(f" Branch: {result['branch']}")
92
+ click.echo(f" Snapshot: {result['snapshot']}")
93
+ click.echo(
94
+ f"\n💡 The snapshot {result['snapshot']} is still available.\n"
95
+ f" To re-upgrade: half_orm dev upgrade"
96
+ )
97
+
98
+ except ReleaseManagerError as e:
99
+ click.echo(utils.Color.red(f"\n❌ {e}"), err=True)
100
+ raise click.Abort()
101
+ except Exception as e:
102
+ click.echo(utils.Color.red(f"\n❌ Unexpected error: {e}"), err=True)
103
+ raise click.Abort()
@@ -49,7 +49,7 @@ class Hop:
49
49
 
50
50
  # PRODUCTION ENVIRONMENT — read-only, no migrations, no dev commands
51
51
  if self.__repo.database.production:
52
- return ['update', 'upgrade', 'bootstrap']
52
+ return ['upgrade', 'rollback']
53
53
 
54
54
  if self.__repo.needs_migration():
55
55
  return ['migrate']
@@ -61,7 +61,7 @@ class Hop:
61
61
 
62
62
  # DEVELOPMENT ENVIRONMENT - Patch development
63
63
  return ['patch', 'release', 'check', 'bootstrap', 'set-git-origin',
64
- 'revert-migration']
64
+ 'revert-migration', 'recover']
65
65
 
66
66
  @property
67
67
  def repo_checked(self):
@@ -1335,6 +1335,29 @@ class Database:
1335
1335
  'dropdb', '--if-exists', snapshot_name
1336
1336
  )
1337
1337
 
1338
+ def restore_from_snapshot(self, snapshot_name: str) -> None:
1339
+ """Restore this database from a snapshot (drop + recreate from template).
1340
+
1341
+ Requires CREATEDB privilege. Call terminate_active_connections() first,
1342
+ then reconnect() afterwards.
1343
+
1344
+ Args:
1345
+ snapshot_name: Name of the snapshot database to restore from.
1346
+ """
1347
+ params = self._get_connection_params()
1348
+ self._execute_pg_command('postgres', params, 'dropdb', self.__name)
1349
+ self._execute_pg_command('postgres', params, 'createdb', '-T', snapshot_name, self.__name)
1350
+
1351
+ def list_snapshots(self) -> list:
1352
+ """Return snapshot names matching {db}_hop_snap_* pattern, sorted ascending."""
1353
+ params = self._get_connection_params()
1354
+ result = self._execute_pg_command(
1355
+ 'postgres', params,
1356
+ 'psql', '-d', 'postgres', '-t', '-c',
1357
+ f"SELECT datname FROM pg_database WHERE datname LIKE '{self.__name}_hop_snap_%' ORDER BY datname"
1358
+ )
1359
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
1360
+
1338
1361
  def get_postgres_version(self) -> tuple:
1339
1362
  """
1340
1363
  Get PostgreSQL server version.
@@ -4,11 +4,23 @@ Decorators for half-orm-dev.
4
4
  Provides common decorators for ReleaseManager and PatchManager.
5
5
  """
6
6
 
7
+ import os
7
8
  import signal
8
9
  import sys
9
10
  import inspect
10
11
  from functools import wraps
11
12
 
13
+ from git.exc import GitCommandError
14
+
15
+
16
+ def _has_recovery_refs(repo) -> bool:
17
+ """Return True if refs/hop/sync/before/* exist (Phase 2 incomplete for some branch)."""
18
+ try:
19
+ refs = repo.hgit._HGit__git_repo.git.for_each_ref('refs/hop/sync/before/')
20
+ return bool(refs.strip())
21
+ except GitCommandError:
22
+ return False
23
+
12
24
 
13
25
  def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
14
26
  """
@@ -43,13 +55,12 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
43
55
  repo = getattr(self, '_repo', self)
44
56
  lock_tag = None
45
57
  locked_branch = None
58
+ # Set to True when Phase 2 pushed some but not all branches.
59
+ # In that case the lock and lock file must survive so 'hop recover'
60
+ # can complete the work.
61
+ _keep_lock_for_recovery = False
46
62
  try:
47
63
  # CRITICAL: Sync ho-prod with origin and validate version BEFORE acquiring any lock
48
- # This ensures:
49
- # 1. ho-prod is up-to-date (pull)
50
- # 2. All branches are fetched (prune)
51
- # 3. Repository hop_version is validated against installed version
52
- # 4. Operation is blocked if version is outdated (prevents dangerous operations)
53
64
  repo.sync_and_validate_ho_prod()
54
65
 
55
66
  # Determine branch name dynamically
@@ -58,6 +69,14 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
58
69
  # Acquire lock
59
70
  lock_tag = repo.hgit.acquire_branch_lock(locked_branch, timeout_minutes=timeout_minutes)
60
71
 
72
+ # Write lock file so 'hop recover' can identify ownership after a crash
73
+ _sync_lock_path = os.path.join(repo.base_dir, '.git', 'hop-sync-lock')
74
+ try:
75
+ with open(_sync_lock_path, 'w') as _f:
76
+ _f.write(lock_tag)
77
+ except OSError:
78
+ pass
79
+
61
80
  # Execute the method
62
81
  result = func(self, *args, **kwargs)
63
82
 
@@ -66,20 +85,27 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
66
85
  sync_result = repo.sync_hop_to_active_branches(
67
86
  reason=f"{func.__name__}"
68
87
  )
69
- # Log sync errors but don't fail the operation
70
88
  if sync_result.get('errors'):
71
89
  for error in sync_result['errors']:
72
90
  print(f"Warning: .hop/ sync error: {error}", file=sys.stderr)
91
+ # Recovery refs remaining means Phase 2 is incomplete
92
+ _keep_lock_for_recovery = _has_recovery_refs(repo)
73
93
  except Exception as e:
74
- # Don't fail the decorated method if sync fails
75
94
  print(f"Warning: Failed to sync .hop/ to active branches: {e}", file=sys.stderr)
95
+ _keep_lock_for_recovery = _has_recovery_refs(repo)
96
+
97
+ if _keep_lock_for_recovery:
98
+ print(
99
+ "Warning: Sync partially failed. "
100
+ "Run 'hop recover' to complete the operation.",
101
+ file=sys.stderr
102
+ )
76
103
 
77
104
  return result
78
105
  finally:
79
- # Always release lock (even on error)
80
- # Block SIGINT during cleanup to prevent Ctrl+C from
81
- # interrupting lock release and leaving an orphan tag.
82
- if lock_tag:
106
+ if lock_tag and not _keep_lock_for_recovery:
107
+ # Normal completion or pre-sync failure: release lock and clean up.
108
+ # Block SIGINT to prevent Ctrl+C from leaving an orphan lock tag.
83
109
  interrupted = False
84
110
  original_handler = signal.getsignal(signal.SIGINT)
85
111
  signal.signal(signal.SIGINT, lambda s, f: setattr(
@@ -93,6 +119,12 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
93
119
  if interrupted:
94
120
  raise KeyboardInterrupt()
95
121
 
122
+ _sync_lock_path = os.path.join(repo.base_dir, '.git', 'hop-sync-lock')
123
+ try:
124
+ os.unlink(_sync_lock_path)
125
+ except FileNotFoundError:
126
+ pass
127
+
96
128
  return wrapper
97
129
  return decorator
98
130
 
@@ -5,6 +5,8 @@ This module provides common file execution functionality used by both
5
5
  PatchManager and BootstrapManager.
6
6
  """
7
7
 
8
+ import ast
9
+ import importlib.util
8
10
  import re
9
11
  import subprocess
10
12
  import sys
@@ -98,6 +100,72 @@ def execute_python_file(file_path: Path, cwd: Optional[Path] = None) -> str:
98
100
  raise FileExecutionError(f"Failed to execute Python file {file_path.name}: {e}") from e
99
101
 
100
102
 
103
+ def _has_run_entrypoint(file_path: Path) -> bool:
104
+ """Return True if the file defines a top-level run() function."""
105
+ try:
106
+ tree = ast.parse(file_path.read_text(encoding='utf-8'))
107
+ except (OSError, SyntaxError):
108
+ return False
109
+ return any(
110
+ isinstance(node, ast.FunctionDef) and node.name == 'run'
111
+ for node in tree.body
112
+ )
113
+
114
+
115
+ def execute_python_bootstrap(file_path: Path, model, cwd: Optional[Path] = None) -> str:
116
+ """
117
+ Execute a Python bootstrap script.
118
+
119
+ Fast path — if the script defines a top-level run(model) function it is
120
+ loaded in-process via importlib and called with the live database model,
121
+ sharing the existing connection.
122
+
123
+ Slow path — scripts without run(model) are executed as a subprocess
124
+ (backwards-compatible with pre-API scripts).
125
+
126
+ Args:
127
+ file_path: Path to Python bootstrap script
128
+ model: halfORM Model instance (shared database connection)
129
+ cwd: Working directory for execution (default: file's parent)
130
+
131
+ Returns:
132
+ Return value of run() converted to str, or subprocess stdout.
133
+ Empty string if run() returns None.
134
+
135
+ Raises:
136
+ FileExecutionError: If execution fails
137
+ """
138
+ if cwd is None:
139
+ cwd = file_path.parent
140
+
141
+ if not _has_run_entrypoint(file_path):
142
+ return execute_python_file(file_path, cwd)
143
+
144
+ module_name = f"_hop_bootstrap_{file_path.stem.replace('-', '_').replace('.', '_')}"
145
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
146
+ module = importlib.util.module_from_spec(spec)
147
+
148
+ cwd_str = str(cwd)
149
+ inserted = cwd_str not in sys.path
150
+ if inserted:
151
+ sys.path.insert(0, cwd_str)
152
+
153
+ try:
154
+ spec.loader.exec_module(module)
155
+ result = module.run(model)
156
+ return str(result) if result is not None else ''
157
+ except FileExecutionError:
158
+ raise
159
+ except Exception as e:
160
+ raise FileExecutionError(
161
+ f"Python execution failed in {file_path.name}: {e}"
162
+ ) from e
163
+ finally:
164
+ if inserted and cwd_str in sys.path:
165
+ sys.path.remove(cwd_str)
166
+ sys.modules.pop(module_name, None)
167
+
168
+
101
169
  _HOP_MARKER = re.compile(r"(--|#)\s*@hop:(bootstrap|data)")
102
170
 
103
171