half-orm-dev 1.0.0a18__tar.gz → 1.0.0a20__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.0a18/half_orm_dev.egg-info → half_orm_dev-1.0.0a20}/PKG-INFO +1 -1
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/main.py +30 -13
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/hgit.py +12 -2
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/patch_manager.py +1 -6
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/release_manager.py +85 -273
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/repo.py +47 -13
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/.gitignore +3 -1
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/git-hooks/pre-commit +12 -0
- half_orm_dev-1.0.0a20/half_orm_dev/templates/git-hooks/pre-push +18 -0
- half_orm_dev-1.0.0a20/half_orm_dev/templates/git-hooks/reference-transaction +34 -0
- half_orm_dev-1.0.0a20/half_orm_dev/version.txt +1 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20/half_orm_dev.egg-info}/PKG-INFO +1 -1
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev.egg-info/SOURCES.txt +3 -1
- half_orm_dev-1.0.0a18/half_orm_dev/version.txt +0 -1
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/AUTHORS +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/LICENSE +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/README.md +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/__init__.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/bootstrap_manager.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/__init__.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/__init__.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/apply.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/bootstrap.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/check.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/clone.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/init.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/migrate.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/patch.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/release.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/restore.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/revert_migration.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/sync.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/todo.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/undo.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/update.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/upgrade.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli_extension.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/database.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/decorators.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/file_executor.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migration_manager.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/modules.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/patch_validator.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/patches/log +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/release_file.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/scripts/repair-metadata.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/MANIFEST.in +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/README +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/conftest_template +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/init_module_template +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/module_template_1 +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/module_template_2 +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/module_template_3 +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/relation_test +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/sql_adapter +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/warning +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/utils.py +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev.egg-info/dependency_links.txt +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev.egg-info/entry_points.txt +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev.egg-info/requires.txt +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev.egg-info/top_level.txt +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/setup.cfg +0 -0
- {half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/setup.py +0 -0
|
@@ -4,6 +4,8 @@ Main CLI module - Creates and configures the CLI group
|
|
|
4
4
|
|
|
5
5
|
import click
|
|
6
6
|
import functools
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
7
9
|
import sys
|
|
8
10
|
from half_orm_dev.repo import Repo, OutdatedHalfORMDevError
|
|
9
11
|
from half_orm import utils
|
|
@@ -39,15 +41,16 @@ class Hop:
|
|
|
39
41
|
but will be blocked by the decorator at execution time.
|
|
40
42
|
"""
|
|
41
43
|
if self.needs_hop_upgrade:
|
|
42
|
-
#
|
|
43
|
-
# Commands will be blocked by decorator, but we need them in the list
|
|
44
|
-
# so Click doesn't show "No such command" error
|
|
45
|
-
return ['check', 'migrate']
|
|
44
|
+
return [] # handled at invocation time in the dev group callback
|
|
46
45
|
|
|
47
46
|
if not self.repo_checked:
|
|
48
47
|
# Outside hop repository - commands for project initialization
|
|
49
48
|
return ['init', 'clone']
|
|
50
49
|
|
|
50
|
+
# PRODUCTION ENVIRONMENT — read-only, no migrations, no dev commands
|
|
51
|
+
if self.__repo.database.production:
|
|
52
|
+
return ['update', 'upgrade', 'bootstrap']
|
|
53
|
+
|
|
51
54
|
if self.__repo.needs_migration():
|
|
52
55
|
return ['migrate']
|
|
53
56
|
|
|
@@ -56,14 +59,9 @@ class Hop:
|
|
|
56
59
|
# Sync-only mode (no metadata)
|
|
57
60
|
return ['sync-package', 'check']
|
|
58
61
|
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return ['update', 'upgrade', 'bootstrap']
|
|
63
|
-
else:
|
|
64
|
-
# DEVELOPMENT ENVIRONMENT - Patch development
|
|
65
|
-
return ['patch', 'release', 'check', 'bootstrap', 'set-git-origin',
|
|
66
|
-
'revert-migration']
|
|
62
|
+
# DEVELOPMENT ENVIRONMENT - Patch development
|
|
63
|
+
return ['patch', 'release', 'check', 'bootstrap', 'set-git-origin',
|
|
64
|
+
'revert-migration']
|
|
67
65
|
|
|
68
66
|
@property
|
|
69
67
|
def repo_checked(self):
|
|
@@ -137,9 +135,28 @@ def create_cli_group():
|
|
|
137
135
|
|
|
138
136
|
@click.group(cls=VersionCheckGroup, invoke_without_command=True)
|
|
139
137
|
@click.pass_context
|
|
140
|
-
@check_version_before_invoke
|
|
141
138
|
def dev(ctx):
|
|
142
139
|
"""halfORM development tools - Git-centric patch management and database synchronization"""
|
|
140
|
+
if hop.needs_hop_upgrade:
|
|
141
|
+
error = hop.hop_upgrade_error
|
|
142
|
+
required = error.required_version
|
|
143
|
+
installed = error.installed_version
|
|
144
|
+
click.echo(f"\n Ce repo requiers half-orm-dev {required} "
|
|
145
|
+
f"(installed : {installed}).")
|
|
146
|
+
click.echo(f" Installing the required version...")
|
|
147
|
+
try:
|
|
148
|
+
subprocess.run(
|
|
149
|
+
[sys.executable, '-m', 'pip', 'install', f'half-orm-dev=={required}'],
|
|
150
|
+
check=True,
|
|
151
|
+
)
|
|
152
|
+
except subprocess.CalledProcessError:
|
|
153
|
+
click.echo(f"\n Installation failed.", err=True)
|
|
154
|
+
click.echo(f" Run manually : pip install half-orm-dev=={required}", err=True)
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
click.echo(f" ✓ half-orm-dev {required} installed. Restarting...\n")
|
|
157
|
+
os.execv(sys.argv[0], sys.argv)
|
|
158
|
+
return
|
|
159
|
+
|
|
143
160
|
if ctx.invoked_subcommand is None:
|
|
144
161
|
# Show repo state when no subcommand is provided
|
|
145
162
|
if hop.repo_checked:
|
|
@@ -367,7 +367,12 @@ class HGit:
|
|
|
367
367
|
# Local git now knows about all remote tags
|
|
368
368
|
"""
|
|
369
369
|
origin = self.__git_repo.remote('origin')
|
|
370
|
-
|
|
370
|
+
marker = Path(self.__git_repo.working_dir) / '.hop' / '.fetching'
|
|
371
|
+
try:
|
|
372
|
+
marker.touch()
|
|
373
|
+
origin.fetch(tags=True)
|
|
374
|
+
finally:
|
|
375
|
+
marker.unlink(missing_ok=True)
|
|
371
376
|
|
|
372
377
|
def tag_exists(self, tag_name: str) -> bool:
|
|
373
378
|
"""
|
|
@@ -443,7 +448,12 @@ class HGit:
|
|
|
443
448
|
# Stale remote refs (deleted branches on remote) are removed
|
|
444
449
|
"""
|
|
445
450
|
origin = self.__git_repo.remote('origin')
|
|
446
|
-
|
|
451
|
+
marker = Path(self.__git_repo.working_dir) / '.hop' / '.fetching'
|
|
452
|
+
try:
|
|
453
|
+
marker.touch()
|
|
454
|
+
origin.fetch(prune=True)
|
|
455
|
+
finally:
|
|
456
|
+
marker.unlink(missing_ok=True)
|
|
447
457
|
|
|
448
458
|
def delete_local_branch(self, branch_name: str) -> None:
|
|
449
459
|
"""
|
|
@@ -18,6 +18,7 @@ from typing import List, Dict, Optional, Tuple, Any
|
|
|
18
18
|
from dataclasses import dataclass
|
|
19
19
|
import click
|
|
20
20
|
from git.exc import GitCommandError
|
|
21
|
+
from packaging.version import Version, InvalidVersion
|
|
21
22
|
|
|
22
23
|
from half_orm import utils
|
|
23
24
|
from half_orm_dev import modules
|
|
@@ -2146,9 +2147,6 @@ class PatchManager:
|
|
|
2146
2147
|
- Commit the updated schema
|
|
2147
2148
|
4. Return to original branch
|
|
2148
2149
|
"""
|
|
2149
|
-
from packaging.version import Version
|
|
2150
|
-
from half_orm_dev.release_file import ReleaseFile
|
|
2151
|
-
|
|
2152
2150
|
original_branch = self._repo.hgit.branch
|
|
2153
2151
|
current_ver = Version(version)
|
|
2154
2152
|
|
|
@@ -2197,9 +2195,6 @@ class PatchManager:
|
|
|
2197
2195
|
Args:
|
|
2198
2196
|
version: Current release version that was just updated
|
|
2199
2197
|
"""
|
|
2200
|
-
from packaging.version import Version
|
|
2201
|
-
from half_orm_dev.release_file import ReleaseFile
|
|
2202
|
-
|
|
2203
2198
|
if not hasattr(self, '_pending_higher_releases') or not self._pending_higher_releases:
|
|
2204
2199
|
return
|
|
2205
2200
|
|
|
@@ -13,16 +13,17 @@ import subprocess
|
|
|
13
13
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Optional, Tuple, List, Dict, Literal
|
|
16
|
-
from dataclasses import dataclass
|
|
17
16
|
from datetime import datetime, timezone
|
|
18
17
|
|
|
19
18
|
import click
|
|
20
19
|
|
|
21
20
|
from git.exc import GitCommandError
|
|
21
|
+
from packaging.version import Version, InvalidVersion
|
|
22
22
|
from half_orm_dev.decorators import with_dynamic_branch_lock
|
|
23
23
|
from half_orm import utils
|
|
24
24
|
from half_orm_dev.release_file import ReleaseFile
|
|
25
25
|
|
|
26
|
+
|
|
26
27
|
class ReleaseManagerError(Exception):
|
|
27
28
|
"""Base exception for ReleaseManager operations."""
|
|
28
29
|
pass
|
|
@@ -38,69 +39,6 @@ class ReleaseFileError(ReleaseManagerError):
|
|
|
38
39
|
pass
|
|
39
40
|
|
|
40
41
|
|
|
41
|
-
@dataclass
|
|
42
|
-
class Version:
|
|
43
|
-
"""Semantic version with stage information."""
|
|
44
|
-
major: int
|
|
45
|
-
minor: int
|
|
46
|
-
patch: int
|
|
47
|
-
stage: Optional[str] = None # None, "stage", "rc1", "rc2", "hotfix1", etc.
|
|
48
|
-
|
|
49
|
-
def __str__(self) -> str:
|
|
50
|
-
"""String representation of version."""
|
|
51
|
-
base = f"{self.major}.{self.minor}.{self.patch}"
|
|
52
|
-
if self.stage:
|
|
53
|
-
return f"{base}-{self.stage}"
|
|
54
|
-
return base
|
|
55
|
-
|
|
56
|
-
def __lt__(self, other: 'Version') -> bool:
|
|
57
|
-
"""Compare versions for sorting."""
|
|
58
|
-
# Compare base version first
|
|
59
|
-
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
|
|
60
|
-
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
|
|
61
|
-
|
|
62
|
-
# If base versions equal, compare stages
|
|
63
|
-
# Priority: production (None) > rc > stage > hotfix
|
|
64
|
-
stage_priority = {
|
|
65
|
-
None: 4, # Production (highest)
|
|
66
|
-
'rc': 3, # Release candidate
|
|
67
|
-
'stage': 2, # Development stage
|
|
68
|
-
'hotfix': 1 # Hotfix (lowest)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
# Extract stage type (rc1 → rc, hotfix2 → hotfix)
|
|
72
|
-
self_stage_type = self._get_stage_type()
|
|
73
|
-
other_stage_type = other._get_stage_type()
|
|
74
|
-
|
|
75
|
-
self_priority = stage_priority.get(self_stage_type, 0)
|
|
76
|
-
other_priority = stage_priority.get(other_stage_type, 0)
|
|
77
|
-
|
|
78
|
-
# If different stage types, compare by priority
|
|
79
|
-
if self_priority != other_priority:
|
|
80
|
-
return self_priority < other_priority
|
|
81
|
-
|
|
82
|
-
# Same stage type - compare stage strings for RC/hotfix numbers
|
|
83
|
-
# rc2 > rc1, hotfix2 > hotfix1
|
|
84
|
-
if self.stage and other.stage:
|
|
85
|
-
return self.stage < other.stage
|
|
86
|
-
|
|
87
|
-
return False
|
|
88
|
-
|
|
89
|
-
def _get_stage_type(self) -> Optional[str]:
|
|
90
|
-
"""Extract stage type from stage string."""
|
|
91
|
-
if not self.stage:
|
|
92
|
-
return None
|
|
93
|
-
|
|
94
|
-
if self.stage == 'stage':
|
|
95
|
-
return 'stage'
|
|
96
|
-
elif self.stage.startswith('rc'):
|
|
97
|
-
return 'rc'
|
|
98
|
-
elif self.stage.startswith('hotfix'):
|
|
99
|
-
return 'hotfix'
|
|
100
|
-
|
|
101
|
-
return None
|
|
102
|
-
|
|
103
|
-
|
|
104
42
|
class ReleaseManager:
|
|
105
43
|
"""
|
|
106
44
|
Manages release files and version lifecycle.
|
|
@@ -474,21 +412,16 @@ class ReleaseManager:
|
|
|
474
412
|
if not release_files:
|
|
475
413
|
return None
|
|
476
414
|
|
|
477
|
-
# Parse all valid versions
|
|
478
415
|
versions = []
|
|
479
|
-
for
|
|
416
|
+
for f in release_files:
|
|
480
417
|
try:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
except ReleaseVersionError:
|
|
484
|
-
# Ignore files with invalid format
|
|
418
|
+
versions.append(Version(f.stem))
|
|
419
|
+
except InvalidVersion:
|
|
485
420
|
continue
|
|
486
421
|
|
|
487
422
|
if not versions:
|
|
488
423
|
return None
|
|
489
424
|
|
|
490
|
-
# Sort versions and return latest
|
|
491
|
-
# Version.__lt__ handles sorting with stage priority
|
|
492
425
|
return max(versions)
|
|
493
426
|
|
|
494
427
|
|
|
@@ -559,83 +492,11 @@ class ReleaseManager:
|
|
|
559
492
|
elif increment_type == 'minor':
|
|
560
493
|
return f"{current_version.major}.{current_version.minor + 1}.0"
|
|
561
494
|
elif increment_type == 'patch':
|
|
562
|
-
return f"{current_version.major}.{current_version.minor}.{current_version.
|
|
495
|
+
return f"{current_version.major}.{current_version.minor}.{current_version.micro + 1}"
|
|
563
496
|
|
|
564
497
|
# Should never reach here due to validation above
|
|
565
498
|
raise ReleaseVersionError(f"Unexpected increment type: {increment_type}")
|
|
566
499
|
|
|
567
|
-
@classmethod
|
|
568
|
-
def parse_version_from_filename(cls, filename: str) -> Version:
|
|
569
|
-
"""
|
|
570
|
-
Parse version from release filename.
|
|
571
|
-
|
|
572
|
-
Extracts semantic version and stage from release filename.
|
|
573
|
-
|
|
574
|
-
Supported formats:
|
|
575
|
-
- X.Y.Z.txt → Version(X, Y, Z, stage=None)
|
|
576
|
-
- X.Y.Z-stage.txt → Version(X, Y, Z, stage="stage")
|
|
577
|
-
- X.Y.Z-rc1.txt → Version(X, Y, Z, stage="rc1")
|
|
578
|
-
- X.Y.Z-hotfix1.txt → Version(X, Y, Z, stage="hotfix1")
|
|
579
|
-
|
|
580
|
-
Args:
|
|
581
|
-
filename: Release filename (e.g., "1.3.5-rc2.txt")
|
|
582
|
-
|
|
583
|
-
Returns:
|
|
584
|
-
Version: Parsed version object
|
|
585
|
-
|
|
586
|
-
Raises:
|
|
587
|
-
ReleaseVersionError: If filename format invalid
|
|
588
|
-
|
|
589
|
-
Examples:
|
|
590
|
-
ver = release_mgr.parse_version_from_filename("1.3.5.txt")
|
|
591
|
-
# Version(1, 3, 5, stage=None)
|
|
592
|
-
|
|
593
|
-
ver = release_mgr.parse_version_from_filename("1.4.0-stage.txt")
|
|
594
|
-
# Version(1, 4, 0, stage="stage")
|
|
595
|
-
|
|
596
|
-
ver = release_mgr.parse_version_from_filename("1.3.5-rc2.txt")
|
|
597
|
-
# Version(1, 3, 5, stage="rc2")
|
|
598
|
-
"""
|
|
599
|
-
# Extract just filename if path provided
|
|
600
|
-
filename = Path(filename).name
|
|
601
|
-
|
|
602
|
-
# Validate not empty
|
|
603
|
-
if not filename:
|
|
604
|
-
raise ReleaseVersionError("Invalid format: empty filename")
|
|
605
|
-
|
|
606
|
-
# Must end with .txt
|
|
607
|
-
if not filename.endswith('.txt'):
|
|
608
|
-
raise ReleaseVersionError(f"Invalid format: missing .txt extension in '{filename}'")
|
|
609
|
-
|
|
610
|
-
# Remove .txt extension
|
|
611
|
-
version_str = filename[:-4]
|
|
612
|
-
|
|
613
|
-
# Pattern: X.Y.Z or X.Y.Z-stage or X.Y.Z-rc1 or X.Y.Z-hotfix1
|
|
614
|
-
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-(stage|rc\d+|hotfix\d+))?$'
|
|
615
|
-
|
|
616
|
-
match = re.match(pattern, version_str)
|
|
617
|
-
|
|
618
|
-
if not match:
|
|
619
|
-
raise ReleaseVersionError(
|
|
620
|
-
f"Invalid format: '{filename}' does not match X.Y.Z[-stage].txt pattern"
|
|
621
|
-
)
|
|
622
|
-
|
|
623
|
-
major, minor, patch, stage = match.groups()
|
|
624
|
-
|
|
625
|
-
# Convert to integers
|
|
626
|
-
try:
|
|
627
|
-
major = int(major)
|
|
628
|
-
minor = int(minor)
|
|
629
|
-
patch = int(patch)
|
|
630
|
-
except ValueError:
|
|
631
|
-
raise ReleaseVersionError(f"Invalid format: non-numeric version components in '{filename}'")
|
|
632
|
-
|
|
633
|
-
# Validate non-negative
|
|
634
|
-
if major < 0 or minor < 0 or patch < 0:
|
|
635
|
-
raise ReleaseVersionError(f"Invalid format: negative version numbers in '{filename}'")
|
|
636
|
-
|
|
637
|
-
return Version(major, minor, patch, stage)
|
|
638
|
-
|
|
639
500
|
def get_next_release_version(self) -> Optional[str]:
|
|
640
501
|
"""
|
|
641
502
|
Détermine LA prochaine release à déployer.
|
|
@@ -646,8 +507,7 @@ class ReleaseManager:
|
|
|
646
507
|
production_str = self._get_production_version()
|
|
647
508
|
|
|
648
509
|
for level in ['patch', 'minor', 'major']:
|
|
649
|
-
next_version = self.calculate_next_version(
|
|
650
|
-
self.parse_version_from_filename(f"{production_str}.txt"), level)
|
|
510
|
+
next_version = self.calculate_next_version(Version(production_str), level)
|
|
651
511
|
|
|
652
512
|
# Cherche RC ou patches TOML pour cette version
|
|
653
513
|
rc_pattern = f"{next_version}-rc*.txt"
|
|
@@ -662,12 +522,12 @@ class ReleaseManager:
|
|
|
662
522
|
"""
|
|
663
523
|
Liste tous les fichiers <label> pour une version, triés par numéro.
|
|
664
524
|
|
|
665
|
-
|
|
666
|
-
|
|
525
|
+
RC uses hyphen separator (1.3.6-rc1.txt).
|
|
526
|
+
Post-releases use dot separator (1.3.6.post1.txt).
|
|
667
527
|
"""
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
label_pattern = re.compile(
|
|
528
|
+
sep = '.' if label == 'post' else '-'
|
|
529
|
+
pattern = f"{version}{sep}{label}*.txt"
|
|
530
|
+
label_pattern = re.compile(rf'[.-]{label}(\d+)\.txt$')
|
|
671
531
|
files = list(self._releases_dir.glob(pattern))
|
|
672
532
|
|
|
673
533
|
return sorted(files, key=lambda f: int(re.search(label_pattern, f.name).group(1)))
|
|
@@ -806,7 +666,7 @@ class ReleaseManager:
|
|
|
806
666
|
else:
|
|
807
667
|
# Production: read from hotfix snapshot if it exists
|
|
808
668
|
# This handles the case where we're applying a hotfix release
|
|
809
|
-
hotfix_files = sorted(self._releases_dir.glob(f"{version}
|
|
669
|
+
hotfix_files = sorted(self._releases_dir.glob(f"{version}.post*.txt"))
|
|
810
670
|
if hotfix_files:
|
|
811
671
|
# Apply the latest hotfix
|
|
812
672
|
stage_patches = self.read_release_patches(hotfix_files[-1].name)
|
|
@@ -892,7 +752,7 @@ class ReleaseManager:
|
|
|
892
752
|
all_patches.extend(self.read_release_patches(base_file))
|
|
893
753
|
|
|
894
754
|
# 2. Hotfix patches in order
|
|
895
|
-
hotfix_files = sorted(self._releases_dir.glob(f"{version}
|
|
755
|
+
hotfix_files = sorted(self._releases_dir.glob(f"{version}.post*.txt"))
|
|
896
756
|
for hotfix_file in hotfix_files:
|
|
897
757
|
all_patches.extend(self.read_release_patches(hotfix_file.name))
|
|
898
758
|
|
|
@@ -1372,6 +1232,52 @@ class ReleaseManager:
|
|
|
1372
1232
|
# Best effort - don't fail if checkout back fails
|
|
1373
1233
|
pass
|
|
1374
1234
|
|
|
1235
|
+
def ensure_ho_current(self) -> bool:
|
|
1236
|
+
"""
|
|
1237
|
+
Set up ho-current branch for production servers if not already done.
|
|
1238
|
+
|
|
1239
|
+
Creates ho-current from the current production version's immutable tag
|
|
1240
|
+
and checks it out. Local-only, never pushed to origin.
|
|
1241
|
+
|
|
1242
|
+
Returns:
|
|
1243
|
+
True if ho-current was created and checked out, False otherwise.
|
|
1244
|
+
|
|
1245
|
+
Raises:
|
|
1246
|
+
ReleaseManagerError: If the required version tag is not found locally.
|
|
1247
|
+
"""
|
|
1248
|
+
if not (
|
|
1249
|
+
self._repo.production
|
|
1250
|
+
and self._repo.hgit.branch == 'ho-prod'
|
|
1251
|
+
and not self._repo.hgit.branch_exists('ho-current')
|
|
1252
|
+
):
|
|
1253
|
+
return False
|
|
1254
|
+
|
|
1255
|
+
try:
|
|
1256
|
+
current_version = self._repo.database.last_release_s
|
|
1257
|
+
except Exception as e:
|
|
1258
|
+
raise ReleaseManagerError(
|
|
1259
|
+
f"Cannot read current production version from database: {e}"
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
current_tag = f'v{current_version}'
|
|
1263
|
+
tag_exists = any(
|
|
1264
|
+
t.name == current_tag
|
|
1265
|
+
for t in self._repo.hgit._HGit__git_repo.tags
|
|
1266
|
+
)
|
|
1267
|
+
if not tag_exists:
|
|
1268
|
+
raise ReleaseManagerError(
|
|
1269
|
+
f"Cannot set up ho-current: tag {current_tag} not found locally.\n"
|
|
1270
|
+
f"Run 'hop update' to fetch tags first."
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
self._repo.hgit.create_branch_from_tag('ho-current', current_tag)
|
|
1274
|
+
self._repo.hgit._HGit__git_repo.heads['ho-current'].checkout()
|
|
1275
|
+
click.echo(
|
|
1276
|
+
f" ℹ ho-current branch created from {current_tag} (local only).\n"
|
|
1277
|
+
f" Production servers use ho-current instead of ho-prod."
|
|
1278
|
+
)
|
|
1279
|
+
return True
|
|
1280
|
+
|
|
1375
1281
|
def update_production(self) -> dict:
|
|
1376
1282
|
"""
|
|
1377
1283
|
Fetch tags and list available releases for production upgrade (read-only).
|
|
@@ -1430,30 +1336,8 @@ class ReleaseManager:
|
|
|
1430
1336
|
f"Cannot read current production version from database: {e}"
|
|
1431
1337
|
)
|
|
1432
1338
|
|
|
1433
|
-
# Migration: production servers that still track ho-prod instead of
|
|
1434
|
-
|
|
1435
|
-
# and switch to it — local-only, no push to origin.
|
|
1436
|
-
if (
|
|
1437
|
-
self._repo.production
|
|
1438
|
-
and self._repo.hgit.branch == 'ho-prod'
|
|
1439
|
-
and not self._repo.hgit.branch_exists('ho-current')
|
|
1440
|
-
):
|
|
1441
|
-
current_tag = f'v{current_version}'
|
|
1442
|
-
tag_exists = any(
|
|
1443
|
-
t.name == current_tag
|
|
1444
|
-
for t in self._repo.hgit._HGit__git_repo.tags
|
|
1445
|
-
)
|
|
1446
|
-
if not tag_exists:
|
|
1447
|
-
raise ReleaseManagerError(
|
|
1448
|
-
f"Cannot migrate to ho-current: tag {current_tag} not found locally.\n"
|
|
1449
|
-
f"Run 'hop update' again after ensuring tags are fetched."
|
|
1450
|
-
)
|
|
1451
|
-
self._repo.hgit.create_branch_from_tag('ho-current', current_tag)
|
|
1452
|
-
self._repo.hgit._HGit__git_repo.heads['ho-current'].checkout()
|
|
1453
|
-
click.echo(
|
|
1454
|
-
f" ℹ Migrated to ho-current (created from {current_tag}, local only).\n"
|
|
1455
|
-
f" Production servers should now use ho-current instead of ho-prod."
|
|
1456
|
-
)
|
|
1339
|
+
# Migration: production servers that still track ho-prod instead of ho-current.
|
|
1340
|
+
self.ensure_ho_current()
|
|
1457
1341
|
|
|
1458
1342
|
# 3. Build list of available releases with details
|
|
1459
1343
|
available_releases = []
|
|
@@ -1483,7 +1367,7 @@ class ReleaseManager:
|
|
|
1483
1367
|
patches = [line.strip() for line in content.split('\n') if line.strip()]
|
|
1484
1368
|
|
|
1485
1369
|
# Only include releases newer than current version
|
|
1486
|
-
if
|
|
1370
|
+
if Version(version) > Version(current_version):
|
|
1487
1371
|
available_releases.append({
|
|
1488
1372
|
'tag': tag,
|
|
1489
1373
|
'version': version,
|
|
@@ -1550,43 +1434,19 @@ class ReleaseManager:
|
|
|
1550
1434
|
except Exception as e:
|
|
1551
1435
|
raise ReleaseManagerError(f"Failed to read tags from repository: {e}")
|
|
1552
1436
|
|
|
1553
|
-
# Filter for release tags
|
|
1554
|
-
release_pattern = re.compile(r'^v\d+\.\d+\.\d+(-rc\d
|
|
1437
|
+
# Filter for release tags: v1.3.5, v1.3.5-rc1, v1.3.5.post1
|
|
1438
|
+
release_pattern = re.compile(r'^v\d+\.\d+\.\d+(-rc\d+|\.post\d+)?$')
|
|
1555
1439
|
release_tags = []
|
|
1556
1440
|
|
|
1557
1441
|
for tag in all_tags:
|
|
1558
1442
|
tag_name = tag.name
|
|
1559
1443
|
if release_pattern.match(tag_name):
|
|
1560
|
-
# Filter RC tags unless explicitly allowed
|
|
1561
1444
|
if '-rc' in tag_name and not allow_rc:
|
|
1562
1445
|
continue
|
|
1563
1446
|
release_tags.append(tag_name)
|
|
1564
1447
|
|
|
1565
|
-
# Sort
|
|
1566
|
-
|
|
1567
|
-
"""Extract sortable version tuple from tag name."""
|
|
1568
|
-
# Remove 'v' prefix
|
|
1569
|
-
version_str = tag_name[1:]
|
|
1570
|
-
|
|
1571
|
-
# Split version and suffix
|
|
1572
|
-
if '-rc' in version_str:
|
|
1573
|
-
base_ver, rc_suffix = version_str.split('-rc')
|
|
1574
|
-
rc_num = int(rc_suffix)
|
|
1575
|
-
suffix_weight = (1, rc_num) # RC comes before production
|
|
1576
|
-
elif '-hotfix' in version_str:
|
|
1577
|
-
base_ver, hotfix_suffix = version_str.split('-hotfix')
|
|
1578
|
-
hotfix_num = int(hotfix_suffix)
|
|
1579
|
-
suffix_weight = (2, hotfix_num) # Hotfix comes after production
|
|
1580
|
-
else:
|
|
1581
|
-
base_ver = version_str
|
|
1582
|
-
suffix_weight = (1.5, 0) # Production between RC and hotfix
|
|
1583
|
-
|
|
1584
|
-
# Parse base version
|
|
1585
|
-
major, minor, patch = map(int, base_ver.split('.'))
|
|
1586
|
-
|
|
1587
|
-
return (major, minor, patch, suffix_weight)
|
|
1588
|
-
|
|
1589
|
-
release_tags.sort(key=version_key)
|
|
1448
|
+
# Sort using packaging.version (PEP 440 ordering)
|
|
1449
|
+
release_tags.sort(key=lambda t: Version(t[1:]))
|
|
1590
1450
|
|
|
1591
1451
|
return release_tags
|
|
1592
1452
|
|
|
@@ -1621,77 +1481,31 @@ class ReleaseManager:
|
|
|
1621
1481
|
path = mgr._calculate_upgrade_path("1.4.0", "1.4.0")
|
|
1622
1482
|
# → []
|
|
1623
1483
|
"""
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
target_version = self.parse_version_from_filename(f"{target}.txt")
|
|
1484
|
+
current_version = Version(current)
|
|
1485
|
+
target_version = Version(target)
|
|
1627
1486
|
|
|
1628
|
-
# If same version, no upgrade needed
|
|
1629
1487
|
if current == target:
|
|
1630
1488
|
return []
|
|
1631
1489
|
|
|
1632
|
-
# Get all available release tags (production only)
|
|
1633
1490
|
available_tags = self._get_available_release_tags(allow_rc=False)
|
|
1634
1491
|
|
|
1635
|
-
# Extract versions from tags and parse them
|
|
1636
1492
|
available_versions = []
|
|
1637
1493
|
for tag in available_tags:
|
|
1638
|
-
# Remove 'v' prefix: v1.3.6 → 1.3.6
|
|
1639
1494
|
version_str = tag[1:] if tag.startswith('v') else tag
|
|
1640
|
-
|
|
1641
|
-
# Skip if not a valid production version format
|
|
1642
|
-
if not re.match(r'^\d+\.\d+\.\d+$', version_str):
|
|
1643
|
-
continue
|
|
1644
|
-
|
|
1645
1495
|
try:
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
except (ReleaseManagerError, ValueError):
|
|
1496
|
+
available_versions.append((version_str, Version(version_str)))
|
|
1497
|
+
except InvalidVersion:
|
|
1649
1498
|
continue
|
|
1650
1499
|
|
|
1651
|
-
|
|
1652
|
-
available_versions.sort(key=lambda x: (x[1].major, x[1].minor, x[1].patch))
|
|
1500
|
+
available_versions.sort(key=lambda x: x[1])
|
|
1653
1501
|
|
|
1654
|
-
# Build sequential path from current to target
|
|
1655
1502
|
path = []
|
|
1656
1503
|
for version_str, version in available_versions:
|
|
1657
|
-
|
|
1658
|
-
if (version.major, version.minor, version.patch) <= \
|
|
1659
|
-
(current_version.major, current_version.minor, current_version.patch):
|
|
1660
|
-
continue
|
|
1661
|
-
|
|
1662
|
-
# Add versions <= target
|
|
1663
|
-
if (version.major, version.minor, version.patch) <= \
|
|
1664
|
-
(target_version.major, target_version.minor, target_version.patch):
|
|
1504
|
+
if current_version < version <= target_version:
|
|
1665
1505
|
path.append(version_str)
|
|
1666
1506
|
|
|
1667
1507
|
return path
|
|
1668
1508
|
|
|
1669
|
-
def _version_is_newer(self, version1: str, version2: str) -> bool:
|
|
1670
|
-
"""
|
|
1671
|
-
Compare two version strings to check if version1 is newer than version2.
|
|
1672
|
-
|
|
1673
|
-
Args:
|
|
1674
|
-
version1: First version (e.g., "1.3.6", "1.3.6-rc1")
|
|
1675
|
-
version2: Second version (e.g., "1.3.5")
|
|
1676
|
-
|
|
1677
|
-
Returns:
|
|
1678
|
-
bool: True if version1 > version2
|
|
1679
|
-
|
|
1680
|
-
Examples:
|
|
1681
|
-
_version_is_newer("1.3.6", "1.3.5") # → True
|
|
1682
|
-
_version_is_newer("1.3.5", "1.3.6") # → False
|
|
1683
|
-
_version_is_newer("1.3.6-rc1", "1.3.5") # → True
|
|
1684
|
-
"""
|
|
1685
|
-
# Extract base versions (remove suffix)
|
|
1686
|
-
base1 = version1.split('-')[0]
|
|
1687
|
-
base2 = version2.split('-')[0]
|
|
1688
|
-
|
|
1689
|
-
# Parse versions
|
|
1690
|
-
parts1 = tuple(map(int, base1.split('.')))
|
|
1691
|
-
parts2 = tuple(map(int, base2.split('.')))
|
|
1692
|
-
|
|
1693
|
-
return parts1 > parts2
|
|
1694
|
-
|
|
1695
1509
|
def upgrade_production(
|
|
1696
1510
|
self,
|
|
1697
1511
|
to_version: Optional[str] = None,
|
|
@@ -2431,8 +2245,6 @@ class ReleaseManager:
|
|
|
2431
2245
|
Returns:
|
|
2432
2246
|
Version string of base release, or None if should use prod schema
|
|
2433
2247
|
"""
|
|
2434
|
-
from packaging.version import Version
|
|
2435
|
-
|
|
2436
2248
|
new_ver = Version(new_version)
|
|
2437
2249
|
model_dir = Path(self._repo.model_dir)
|
|
2438
2250
|
|
|
@@ -3114,7 +2926,7 @@ class ReleaseManager:
|
|
|
3114
2926
|
return 1
|
|
3115
2927
|
|
|
3116
2928
|
last_file = files[-1].name
|
|
3117
|
-
reg_ex = rf'
|
|
2929
|
+
reg_ex = rf'[.-]{label}(\d+)\.txt'
|
|
3118
2930
|
match = re.search(reg_ex, last_file)
|
|
3119
2931
|
if match:
|
|
3120
2932
|
last_num = int(match.group(1))
|
|
@@ -3303,26 +3115,26 @@ class ReleaseManager:
|
|
|
3303
3115
|
f" 3. OR move to another release (edit patches file manually)"
|
|
3304
3116
|
)
|
|
3305
3117
|
|
|
3306
|
-
# 3. Determine next
|
|
3307
|
-
|
|
3308
|
-
hotfix_tag = f"v{version}
|
|
3118
|
+
# 3. Determine next post-release number
|
|
3119
|
+
post_num = self._get_latest_label_number(version, 'post')
|
|
3120
|
+
hotfix_tag = f"v{version}.post{post_num}"
|
|
3309
3121
|
|
|
3310
3122
|
# 4. Switch to ho-prod and merge
|
|
3311
3123
|
self._repo.hgit.checkout("ho-prod")
|
|
3312
3124
|
|
|
3313
3125
|
# Merge ho-release/X.Y.Z into ho-prod
|
|
3314
|
-
merge_msg = f"[release] Merge hotfix %{version}
|
|
3126
|
+
merge_msg = f"[release] Merge hotfix %{version}.post{post_num}"
|
|
3315
3127
|
self._repo.hgit.merge(current_branch, message=merge_msg)
|
|
3316
3128
|
|
|
3317
|
-
# 5. Create
|
|
3129
|
+
# 5. Create post-release snapshot file from staged patches
|
|
3318
3130
|
toml_file = self._releases_dir / f"{version}-patches.toml"
|
|
3319
|
-
hotfix_file = self._releases_dir / f"{version}
|
|
3131
|
+
hotfix_file = self._releases_dir / f"{version}.post{post_num}.txt"
|
|
3320
3132
|
|
|
3321
3133
|
if release_file.exists():
|
|
3322
3134
|
# Get staged patches from TOML file
|
|
3323
3135
|
staged_patches = release_file.get_patches(status="staged")
|
|
3324
3136
|
|
|
3325
|
-
# Write snapshot to
|
|
3137
|
+
# Write snapshot to post-release TXT file (production format)
|
|
3326
3138
|
hotfix_file.write_text("\n".join(staged_patches) + "\n" if staged_patches else "", encoding='utf-8')
|
|
3327
3139
|
# Delete TOML patches file (no longer needed)
|
|
3328
3140
|
if toml_file.exists():
|
|
@@ -3333,12 +3145,12 @@ class ReleaseManager:
|
|
|
3333
3145
|
|
|
3334
3146
|
# 7. Commit release file changes and sync to active branches
|
|
3335
3147
|
sync_result = self._repo.commit_and_sync_to_active_branches(
|
|
3336
|
-
message=f"[HOP] Finalize hotfix %{version}
|
|
3337
|
-
reason=f"hotfix {version}
|
|
3148
|
+
message=f"[HOP] Finalize hotfix %{version}.post{post_num} release files",
|
|
3149
|
+
reason=f"hotfix {version}.post{post_num}"
|
|
3338
3150
|
)
|
|
3339
3151
|
|
|
3340
|
-
# 8. Create
|
|
3341
|
-
self._repo.hgit.create_tag(hotfix_tag, f"Hotfix release %{version}
|
|
3152
|
+
# 8. Create post-release tag on ho-prod
|
|
3153
|
+
self._repo.hgit.create_tag(hotfix_tag, f"Hotfix release %{version}.post{post_num}")
|
|
3342
3154
|
self._repo.hgit.push_tag(hotfix_tag)
|
|
3343
3155
|
|
|
3344
3156
|
deleted_branches = []
|
|
@@ -484,6 +484,14 @@ class Repo:
|
|
|
484
484
|
'errors': []
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
if self.production:
|
|
488
|
+
raise RepoError(
|
|
489
|
+
"Repository migration is not available on a production server.\n\n"
|
|
490
|
+
" Production servers are read-only: no commits or pushes to origin.\n"
|
|
491
|
+
" Run 'hop migrate' on a development machine first, then deploy."
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
self._migration_running = True
|
|
487
495
|
try:
|
|
488
496
|
# Create migration manager
|
|
489
497
|
migration_mgr = MigrationManager(self)
|
|
@@ -584,18 +592,17 @@ class Repo:
|
|
|
584
592
|
print(f" You are now on ho-prod", file=sys.stderr)
|
|
585
593
|
|
|
586
594
|
except RepoError:
|
|
587
|
-
# Re-raise RepoError (for branch check)
|
|
588
595
|
raise
|
|
589
596
|
except MigrationManagerError as e:
|
|
590
|
-
# Log migration errors
|
|
591
597
|
error_msg = f"Migration failed: {e}"
|
|
592
598
|
result['errors'].append(error_msg)
|
|
593
599
|
raise RepoError(error_msg) from e
|
|
594
600
|
except Exception as e:
|
|
595
|
-
# Catch any unexpected errors
|
|
596
601
|
error_msg = f"Unexpected migration error: {e}"
|
|
597
602
|
result['errors'].append(error_msg)
|
|
598
603
|
raise RepoError(error_msg) from e
|
|
604
|
+
finally:
|
|
605
|
+
self._migration_running = False
|
|
599
606
|
|
|
600
607
|
return result
|
|
601
608
|
|
|
@@ -918,9 +925,11 @@ class Repo:
|
|
|
918
925
|
installed_version = hop_version()
|
|
919
926
|
required_version = self.__config.hop_version
|
|
920
927
|
|
|
921
|
-
# Validate version compatibility
|
|
922
|
-
|
|
923
|
-
|
|
928
|
+
# Validate version compatibility — strict: installed must equal required.
|
|
929
|
+
# Skip during migration: the config is being updated, versions are transiently mismatched.
|
|
930
|
+
if not getattr(self, '_migration_running', False):
|
|
931
|
+
if self.compare_versions(installed_version, required_version) != 0:
|
|
932
|
+
raise OutdatedHalfORMDevError(required_version, installed_version)
|
|
924
933
|
|
|
925
934
|
except RepoError:
|
|
926
935
|
# Re-raise RepoError (dirty working directory)
|
|
@@ -928,16 +937,14 @@ class Repo:
|
|
|
928
937
|
except OutdatedHalfORMDevError:
|
|
929
938
|
# Re-raise version error (repository was updated to newer version)
|
|
930
939
|
raise
|
|
931
|
-
except
|
|
932
|
-
#
|
|
933
|
-
# and continue
|
|
940
|
+
except (GitCommandError, IndexError, KeyError, ValueError) as e:
|
|
941
|
+
# Git/network errors (offline mode, missing branch, no remote, etc.)
|
|
942
|
+
# Restore branch if needed and continue — these are non-fatal.
|
|
934
943
|
try:
|
|
935
944
|
if current_branch and git_repo:
|
|
936
945
|
git_repo.heads[current_branch].checkout()
|
|
937
946
|
except (GitCommandError, IndexError, TypeError):
|
|
938
947
|
pass
|
|
939
|
-
# Log but don't fail (offline mode, no remote, etc.)
|
|
940
|
-
# Only critical errors (dirty repo, version mismatch) should block
|
|
941
948
|
|
|
942
949
|
def commit_and_sync_to_active_branches(
|
|
943
950
|
self,
|
|
@@ -1450,6 +1457,17 @@ class Repo:
|
|
|
1450
1457
|
if action == 'installed' or overall_action == 'skipped':
|
|
1451
1458
|
overall_action = action
|
|
1452
1459
|
|
|
1460
|
+
# Ensure production-specific entries are in .gitignore (idempotent).
|
|
1461
|
+
gitignore_path = Path(self.__base_dir) / '.gitignore'
|
|
1462
|
+
if gitignore_path.exists():
|
|
1463
|
+
content = gitignore_path.read_text()
|
|
1464
|
+
lines = content.splitlines()
|
|
1465
|
+
missing = [e for e in ('.hop/production', '.hop/.fetching')
|
|
1466
|
+
if e not in lines]
|
|
1467
|
+
if missing:
|
|
1468
|
+
with gitignore_path.open('a') as f:
|
|
1469
|
+
f.write('\n' + '\n'.join(missing) + '\n')
|
|
1470
|
+
|
|
1453
1471
|
return {
|
|
1454
1472
|
'installed': any_installed,
|
|
1455
1473
|
'action': overall_action
|
|
@@ -2934,9 +2952,14 @@ Each script is executed only once unless `--force` is used.
|
|
|
2934
2952
|
)
|
|
2935
2953
|
|
|
2936
2954
|
# Step 3: Clone repository
|
|
2955
|
+
# Production clones fetch ho-prod only — dev branches are irrelevant on production.
|
|
2956
|
+
clone_cmd = ["git", "clone"]
|
|
2957
|
+
if connection_options.get('production'):
|
|
2958
|
+
clone_cmd += ["--single-branch", "--branch", "ho-prod"]
|
|
2959
|
+
clone_cmd += [git_origin, str(dest_path)]
|
|
2937
2960
|
try:
|
|
2938
2961
|
result = subprocess.run(
|
|
2939
|
-
|
|
2962
|
+
clone_cmd,
|
|
2940
2963
|
capture_output=True,
|
|
2941
2964
|
text=True,
|
|
2942
2965
|
check=True,
|
|
@@ -3009,5 +3032,16 @@ Each script is executed only once unless `--force` is used.
|
|
|
3009
3032
|
f"Failed to restore database from schema: {e}"
|
|
3010
3033
|
) from e
|
|
3011
3034
|
|
|
3012
|
-
# Step 9:
|
|
3035
|
+
# Step 9: Set up ho-current for production servers
|
|
3036
|
+
if connection_options.get('production'):
|
|
3037
|
+
# Mark this clone as production (read-only): blocks git commit/push via hooks.
|
|
3038
|
+
production_marker = Path(dest_path) / '.hop' / 'production'
|
|
3039
|
+
production_marker.parent.mkdir(parents=True, exist_ok=True)
|
|
3040
|
+
production_marker.touch()
|
|
3041
|
+
try:
|
|
3042
|
+
repo.release_manager.ensure_ho_current()
|
|
3043
|
+
except Exception as e:
|
|
3044
|
+
print(f" ⚠️ Warning: Could not set up ho-current: {e}", file=sys.stderr)
|
|
3045
|
+
|
|
3046
|
+
# Step 10: Install Git hooks
|
|
3013
3047
|
repo.install_git_hooks()
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
|
|
3
3
|
# Half-ORM pre-commit hook
|
|
4
|
+
# 0. Blocks commits on production servers (read-only)
|
|
4
5
|
# 1. Checks if current ho-* branch exists on remote origin
|
|
5
6
|
# 2. Protects ho-prod branch from direct commits
|
|
6
7
|
# 3. Optionally calls pre-commit-custom if it exists
|
|
7
8
|
# Generated by half_orm_dev
|
|
8
9
|
|
|
10
|
+
# =============================================================================
|
|
11
|
+
# PRODUCTION SERVER GUARD
|
|
12
|
+
# =============================================================================
|
|
13
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
14
|
+
if [ -f "${REPO_ROOT}/.hop/production" ]; then
|
|
15
|
+
echo "ERROR: git commit is not allowed on a production server." >&2
|
|
16
|
+
echo " This repository is read-only (production mode)." >&2
|
|
17
|
+
echo " Changes are deployed via 'hop upgrade', never committed directly." >&2
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
9
21
|
# Get current branch
|
|
10
22
|
CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
|
|
11
23
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
|
|
3
|
+
# Half-ORM pre-push hook
|
|
4
|
+
# Blocks git push on production servers (read-only mode).
|
|
5
|
+
# Generated by half_orm_dev
|
|
6
|
+
|
|
7
|
+
# =============================================================================
|
|
8
|
+
# PRODUCTION SERVER GUARD
|
|
9
|
+
# =============================================================================
|
|
10
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
11
|
+
if [ -f "${REPO_ROOT}/.hop/production" ]; then
|
|
12
|
+
echo "ERROR: git push is not allowed on a production server." >&2
|
|
13
|
+
echo " This repository is read-only (production mode)." >&2
|
|
14
|
+
echo " Changes are deployed via 'hop upgrade', never pushed directly." >&2
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
exit 0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
|
|
3
|
+
# Half-ORM reference-transaction hook
|
|
4
|
+
# Blocks tag creation on production servers (read-only mode).
|
|
5
|
+
# Generated by half_orm_dev
|
|
6
|
+
|
|
7
|
+
# Only act on the "prepared" phase (before the transaction is committed).
|
|
8
|
+
if [ "$1" != "prepared" ]; then
|
|
9
|
+
exit 0
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
13
|
+
if [ ! -f "${REPO_ROOT}/.hop/production" ]; then
|
|
14
|
+
exit 0
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# Allow tag updates that originate from an internal hop fetch operation.
|
|
18
|
+
# hop sets .hop/.fetching before calling git fetch and removes it after.
|
|
19
|
+
if [ -f "${REPO_ROOT}/.hop/.fetching" ]; then
|
|
20
|
+
exit 0
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# Block any local tag creation.
|
|
24
|
+
while read -r _ _ ref_name; do
|
|
25
|
+
case "$ref_name" in
|
|
26
|
+
refs/tags/*)
|
|
27
|
+
echo "ERROR: git tag is not allowed on a production server." >&2
|
|
28
|
+
echo " This repository is read-only (production mode)." >&2
|
|
29
|
+
exit 1
|
|
30
|
+
;;
|
|
31
|
+
esac
|
|
32
|
+
done
|
|
33
|
+
|
|
34
|
+
exit 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0-a20
|
|
@@ -72,4 +72,6 @@ half_orm_dev/templates/relation_test
|
|
|
72
72
|
half_orm_dev/templates/sql_adapter
|
|
73
73
|
half_orm_dev/templates/warning
|
|
74
74
|
half_orm_dev/templates/git-hooks/pre-commit
|
|
75
|
-
half_orm_dev/templates/git-hooks/
|
|
75
|
+
half_orm_dev/templates/git-hooks/pre-push
|
|
76
|
+
half_orm_dev/templates/git-hooks/prepare-commit-msg
|
|
77
|
+
half_orm_dev/templates/git-hooks/reference-transaction
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
1.0.0-a18
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/cli/commands/revert_migration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py
RENAMED
|
File without changes
|
{half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{half_orm_dev-1.0.0a18 → half_orm_dev-1.0.0a20}/half_orm_dev/templates/git-hooks/prepare-commit-msg
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|