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.
- {half_orm_dev-1.0.0a28/half_orm_dev.egg-info → half_orm_dev-1.0.0a30}/PKG-INFO +1 -1
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/bootstrap_manager.py +4 -8
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/__init__.py +4 -3
- half_orm_dev-1.0.0a30/half_orm_dev/cli/commands/recover.py +46 -0
- half_orm_dev-1.0.0a30/half_orm_dev/cli/commands/rollback.py +103 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/main.py +2 -2
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/database.py +23 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/decorators.py +43 -11
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/release_manager.py +200 -107
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/repo.py +155 -0
- half_orm_dev-1.0.0a30/half_orm_dev/version.txt +1 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30/half_orm_dev.egg-info}/PKG-INFO +1 -1
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/SOURCES.txt +2 -1
- half_orm_dev-1.0.0a28/half_orm_dev/cli/commands/update.py +0 -73
- half_orm_dev-1.0.0a28/half_orm_dev/version.txt +0 -1
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/AUTHORS +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/LICENSE +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/README.md +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/__init__.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/__init__.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/apply.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/bootstrap.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/check.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/clone.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/init.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/migrate.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/patch.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/release.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/restore.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/revert_migration.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/sync.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/todo.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/undo.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli/commands/upgrade.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/cli_extension.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/file_executor.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/hgit.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/migration_manager.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/modules.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patch_manager.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patch_validator.py +0 -0
- {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
- {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
- {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
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patches/log +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/py.typed +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/release_file.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/scripts/repair-metadata.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/.gitignore +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/MANIFEST.in +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/README +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/conftest_template +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/git-hooks/pre-push +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/git-hooks/reference-transaction +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/init_module_template +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/module_template_1 +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/module_template_2 +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/module_template_3 +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/relation_test +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/sql_adapter +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/templates/warning +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev/utils.py +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/dependency_links.txt +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/entry_points.txt +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/requires.txt +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/half_orm_dev.egg-info/top_level.txt +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/setup.cfg +0 -0
- {half_orm_dev-1.0.0a28 → half_orm_dev-1.0.0a30}/setup.py +0 -0
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 ['
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|