half-orm-dev 1.0.0a28__tar.gz → 1.0.0a30__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.0a28/half_orm_dev.egg-info → half_orm_dev-1.0.0a30}/PKG-INFO +1 -1
  2. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/bootstrap_manager.py +4 -8
  3. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/__init__.py +4 -3
  4. half_orm_dev-1.0.0a30/half_orm_dev/cli/commands/recover.py +46 -0
  5. half_orm_dev-1.0.0a30/half_orm_dev/cli/commands/rollback.py +103 -0
  6. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/main.py +2 -2
  7. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/database.py +23 -0
  8. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/decorators.py +43 -11
  9. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/release_manager.py +200 -107
  10. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/repo.py +155 -0
  11. half_orm_dev-1.0.0a30/half_orm_dev/version.txt +1 -0
  12. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30/half_orm_dev.egg-info}/PKG-INFO +1 -1
  13. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/SOURCES.txt +2 -1
  14. half_orm_dev-1.0.0a28/half_orm_dev/cli/commands/update.py +0 -73
  15. half_orm_dev-1.0.0a28/half_orm_dev/version.txt +0 -1
  16. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/AUTHORS +0 -0
  17. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/LICENSE +0 -0
  18. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/README.md +0 -0
  19. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/__init__.py +0 -0
  20. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/__init__.py +0 -0
  21. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/apply.py +0 -0
  22. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/bootstrap.py +0 -0
  23. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/check.py +0 -0
  24. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/clone.py +0 -0
  25. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/init.py +0 -0
  26. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/migrate.py +0 -0
  27. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/patch.py +0 -0
  28. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/release.py +0 -0
  29. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/restore.py +0 -0
  30. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/revert_migration.py +0 -0
  31. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
  32. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/sync.py +0 -0
  33. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/todo.py +0 -0
  34. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/undo.py +0 -0
  35. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/upgrade.py +0 -0
  36. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli_extension.py +0 -0
  37. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/file_executor.py +0 -0
  38. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/hgit.py +0 -0
  39. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migration_manager.py +0 -0
  40. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  41. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  42. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
  43. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
  44. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
  45. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
  46. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +0 -0
  47. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
  48. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/1/0/0/a20/01_update_gitignore.py +0 -0
  49. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -0
  50. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/modules.py +0 -0
  51. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patch_manager.py +0 -0
  52. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patch_validator.py +0 -0
  53. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  54. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  55. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  56. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patches/log +0 -0
  57. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  58. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/py.typed +0 -0
  59. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/release_file.py +0 -0
  60. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/scripts/repair-metadata.py +0 -0
  61. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/.gitignore +0 -0
  62. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/MANIFEST.in +0 -0
  63. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/README +0 -0
  64. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/conftest_template +0 -0
  65. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  66. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/git-hooks/pre-push +0 -0
  67. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  68. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/git-hooks/reference-transaction +0 -0
  69. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/init_module_template +0 -0
  70. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/module_template_1 +0 -0
  71. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/module_template_2 +0 -0
  72. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/module_template_3 +0 -0
  73. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/pyproject.toml +0 -0
  74. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/relation_test +0 -0
  75. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/sql_adapter +0 -0
  76. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/warning +0 -0
  77. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/utils.py +0 -0
  78. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  79. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/entry_points.txt +0 -0
  80. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/requires.txt +0 -0
  81. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/top_level.txt +0 -0
  82. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/pyproject.toml +0 -0
  83. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/setup.cfg +0 -0
  84. {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 1.0.0a28
3
+ Version: 1.0.0a30
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
@@ -141,12 +141,9 @@ class BootstrapManager:
141
141
  """
142
142
  # If schema or table isn't ready yet (pre-migration state),
143
143
  # return empty set so all bootstrap files are treated as pending.
144
- try:
145
- self._ensure_bootstrap_table()
146
- HopBootstrap = self._repo.database.model.get_relation_class('half_orm_meta.bootstrap')
147
- return {row.filename for row in HopBootstrap()}
148
- except Exception:
149
- return set()
144
+ self._ensure_bootstrap_table()
145
+ HopBootstrap = self._repo.database.model.get_relation_class('half_orm_meta.bootstrap')
146
+ return {row['filename'] for row in HopBootstrap()}
150
147
 
151
148
  def get_pending_files(
152
149
  self,
@@ -167,7 +164,6 @@ class BootstrapManager:
167
164
  """
168
165
  all_files = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
169
166
  executed = self.get_executed_files()
170
-
171
167
  return [f for f in all_files if f.name not in executed]
172
168
 
173
169
  def execute_file(self, file_path: Path) -> None:
@@ -270,7 +266,7 @@ class BootstrapManager:
270
266
  result['executed'].append(filename)
271
267
  continue
272
268
 
273
- if filename in executed_set:
269
+ if not force and filename in executed_set:
274
270
  result['skipped'].append(filename)
275
271
  continue
276
272
 
@@ -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