half-orm-dev 1.0.0a26__tar.gz → 1.0.0a28__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.0a26/half_orm_dev.egg-info → half_orm_dev-1.0.0a28}/PKG-INFO +1 -1
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/bootstrap_manager.py +22 -25
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/migrate.py +1 -1
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/set_git_origin.py +1 -1
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/main.py +2 -2
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/decorators.py +6 -4
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/hgit.py +89 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migration_manager.py +57 -164
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +1 -1
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +1 -1
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/release_manager.py +206 -335
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/repo.py +33 -101
- half_orm_dev-1.0.0a28/half_orm_dev/version.txt +1 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28/half_orm_dev.egg-info}/PKG-INFO +1 -1
- half_orm_dev-1.0.0a26/half_orm_dev/version.txt +0 -1
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/AUTHORS +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/LICENSE +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/README.md +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/__init__.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/__init__.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/__init__.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/apply.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/bootstrap.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/check.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/clone.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/init.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/patch.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/release.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/restore.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/revert_migration.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/sync.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/todo.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/undo.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/update.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli/commands/upgrade.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/cli_extension.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/database.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/file_executor.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/1/0/0/a20/01_update_gitignore.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/modules.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/patch_manager.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/patch_validator.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/patches/log +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/py.typed +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/release_file.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/scripts/repair-metadata.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/.gitignore +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/MANIFEST.in +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/README +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/conftest_template +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/git-hooks/pre-push +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/git-hooks/reference-transaction +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/init_module_template +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/module_template_1 +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/module_template_2 +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/module_template_3 +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/relation_test +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/sql_adapter +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/templates/warning +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev/utils.py +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev.egg-info/SOURCES.txt +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev.egg-info/dependency_links.txt +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev.egg-info/entry_points.txt +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev.egg-info/requires.txt +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/half_orm_dev.egg-info/top_level.txt +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/setup.cfg +0 -0
- {half_orm_dev-1.0.0a26 → half_orm_dev-1.0.0a28}/setup.py +0 -0
|
@@ -62,8 +62,8 @@ class BootstrapManager:
|
|
|
62
62
|
"""
|
|
63
63
|
Ensure half_orm_meta.bootstrap table exists.
|
|
64
64
|
|
|
65
|
-
Creates the table if it doesn't exist
|
|
66
|
-
|
|
65
|
+
Creates the table if it doesn't exist, then reloads the model so
|
|
66
|
+
get_relation_class('half_orm_meta.bootstrap') can find it.
|
|
67
67
|
"""
|
|
68
68
|
sql = """
|
|
69
69
|
CREATE TABLE IF NOT EXISTS half_orm_meta.bootstrap (
|
|
@@ -73,6 +73,7 @@ class BootstrapManager:
|
|
|
73
73
|
);
|
|
74
74
|
"""
|
|
75
75
|
self._repo.database.model.execute_query(sql)
|
|
76
|
+
self._repo.database.model.reconnect(reload=True)
|
|
76
77
|
|
|
77
78
|
def get_bootstrap_files(
|
|
78
79
|
self,
|
|
@@ -135,9 +136,6 @@ class BootstrapManager:
|
|
|
135
136
|
"""
|
|
136
137
|
Get set of already executed filenames from database.
|
|
137
138
|
|
|
138
|
-
Queries half_orm_meta.bootstrap table to get filenames
|
|
139
|
-
that have already been executed.
|
|
140
|
-
|
|
141
139
|
Returns:
|
|
142
140
|
Set of filename strings that have been executed
|
|
143
141
|
"""
|
|
@@ -145,10 +143,8 @@ class BootstrapManager:
|
|
|
145
143
|
# return empty set so all bootstrap files are treated as pending.
|
|
146
144
|
try:
|
|
147
145
|
self._ensure_bootstrap_table()
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
)
|
|
151
|
-
return {row[0] for row in result} if result else set()
|
|
146
|
+
HopBootstrap = self._repo.database.model.get_relation_class('half_orm_meta.bootstrap')
|
|
147
|
+
return {row.filename for row in HopBootstrap()}
|
|
152
148
|
except Exception:
|
|
153
149
|
return set()
|
|
154
150
|
|
|
@@ -198,22 +194,18 @@ class BootstrapManager:
|
|
|
198
194
|
except FileExecutionError as e:
|
|
199
195
|
raise BootstrapManagerError(str(e)) from e
|
|
200
196
|
|
|
201
|
-
def record_execution(self, filename: str, version: str) -> None:
|
|
197
|
+
def record_execution(self, filename: str, version: str, force: bool = False) -> None:
|
|
202
198
|
"""
|
|
203
199
|
Record execution in half_orm_meta.bootstrap table.
|
|
204
200
|
|
|
205
201
|
Args:
|
|
206
202
|
filename: Name of the executed file
|
|
207
203
|
version: Version extracted from filename
|
|
204
|
+
force: If True, upsert (update executed_at on re-run). If False,
|
|
205
|
+
plain insert — a duplicate raises an error revealing a guard bug.
|
|
208
206
|
"""
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
VALUES (%s, %s)
|
|
212
|
-
ON CONFLICT (filename) DO UPDATE SET
|
|
213
|
-
version = EXCLUDED.version,
|
|
214
|
-
executed_at = NOW()
|
|
215
|
-
"""
|
|
216
|
-
self._repo.database.model.execute_query(sql, (filename, version))
|
|
207
|
+
HopBootstrap = self._repo.database.model.get_relation_class('half_orm_meta.bootstrap')
|
|
208
|
+
HopBootstrap(filename=filename, version=version).ho_insert(upsert=force)
|
|
217
209
|
|
|
218
210
|
def run_bootstrap(
|
|
219
211
|
self,
|
|
@@ -253,14 +245,14 @@ class BootstrapManager:
|
|
|
253
245
|
'errors': []
|
|
254
246
|
}
|
|
255
247
|
|
|
248
|
+
executed_set = self.get_executed_files()
|
|
249
|
+
all_files = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
|
|
250
|
+
|
|
256
251
|
if force:
|
|
257
|
-
files_to_execute =
|
|
252
|
+
files_to_execute = all_files
|
|
258
253
|
else:
|
|
259
|
-
files_to_execute =
|
|
260
|
-
|
|
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]
|
|
254
|
+
files_to_execute = [f for f in all_files if f.name not in executed_set]
|
|
255
|
+
result['skipped'] = [f.name for f in all_files if f.name in executed_set]
|
|
264
256
|
|
|
265
257
|
if not files_to_execute:
|
|
266
258
|
return result
|
|
@@ -278,10 +270,15 @@ class BootstrapManager:
|
|
|
278
270
|
result['executed'].append(filename)
|
|
279
271
|
continue
|
|
280
272
|
|
|
273
|
+
if filename in executed_set:
|
|
274
|
+
result['skipped'].append(filename)
|
|
275
|
+
continue
|
|
276
|
+
|
|
281
277
|
try:
|
|
282
278
|
click.echo(f" • Executing {filename}...")
|
|
283
279
|
self.execute_file(file_path)
|
|
284
|
-
self.record_execution(filename, file_version)
|
|
280
|
+
self.record_execution(filename, file_version, force=force)
|
|
281
|
+
executed_set.add(filename)
|
|
285
282
|
result['executed'].append(filename)
|
|
286
283
|
except BootstrapManagerError as e:
|
|
287
284
|
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.
|
|
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:')}")
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
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.
|
|
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
|
|
274
|
-
-
|
|
275
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
373
|
-
if pending:
|
|
298
|
+
try:
|
|
374
299
|
for version_str, migration_dir in pending:
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
raise
|
|
303
|
+
# hop_version setter calls write() internally — one write only
|
|
304
|
+
self._repo.config.hop_version = target_version
|
|
382
305
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
438
|
-
result['errors'].append(f"Failed to create commit: {e}")
|
|
334
|
+
result['errors'].append(f"Migration tag creation failed: {e}")
|
|
439
335
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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)
|