half-orm-dev 0.17.0a14__tar.gz → 0.17.0a15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {half_orm_dev-0.17.0a14/half_orm_dev.egg-info → half_orm_dev-0.17.0a15}/PKG-INFO +1 -1
  2. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/check.py +90 -42
  3. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/upgrade.py +2 -2
  4. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/patch_manager.py +2 -2
  5. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/release_manager.py +206 -150
  6. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/repo.py +53 -95
  7. half_orm_dev-0.17.0a15/half_orm_dev/version.txt +1 -0
  8. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15/half_orm_dev.egg-info}/PKG-INFO +1 -1
  9. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev.egg-info/SOURCES.txt +0 -2
  10. half_orm_dev-0.17.0a14/half_orm_dev/hop.py +0 -167
  11. half_orm_dev-0.17.0a14/half_orm_dev/patch.py +0 -281
  12. half_orm_dev-0.17.0a14/half_orm_dev/version.txt +0 -1
  13. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/AUTHORS +0 -0
  14. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/LICENSE +0 -0
  15. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/README.md +0 -0
  16. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/__init__.py +0 -0
  17. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/__init__.py +0 -0
  18. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/__init__.py +0 -0
  19. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/apply.py +0 -0
  20. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/clone.py +0 -0
  21. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/init.py +0 -0
  22. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/new.py +0 -0
  23. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/patch.py +0 -0
  24. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/release.py +0 -0
  25. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/restore.py +0 -0
  26. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/sync.py +0 -0
  27. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/todo.py +0 -0
  28. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/undo.py +0 -0
  29. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/commands/update.py +0 -0
  30. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli/main.py +0 -0
  31. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/cli_extension.py +0 -0
  32. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/database.py +0 -0
  33. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/decorators.py +0 -0
  34. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/hgit.py +0 -0
  35. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/manifest.py +0 -0
  36. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/modules.py +0 -0
  37. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/patch_validator.py +0 -0
  38. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  39. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  40. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  41. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/patches/log +0 -0
  42. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  43. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/.gitignore +0 -0
  44. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/MANIFEST.in +0 -0
  45. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/Pipfile +0 -0
  46. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/README +0 -0
  47. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/conftest_template +0 -0
  48. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  49. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  50. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/init_module_template +0 -0
  51. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/module_template_1 +0 -0
  52. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/module_template_2 +0 -0
  53. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/module_template_3 +0 -0
  54. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/pyproject.toml +0 -0
  55. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/relation_test +0 -0
  56. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/sql_adapter +0 -0
  57. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/templates/warning +0 -0
  58. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev/utils.py +0 -0
  59. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  60. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev.egg-info/requires.txt +0 -0
  61. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/half_orm_dev.egg-info/top_level.txt +0 -0
  62. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/pyproject.toml +0 -0
  63. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/setup.cfg +0 -0
  64. {half_orm_dev-0.17.0a14 → half_orm_dev-0.17.0a15}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 0.17.0a14
3
+ Version: 0.17.0a15
4
4
  Summary: half_orm development Framework.
5
5
  Author-email: Joël Maïzi <joel.maizi@collorg.org>
6
6
  License-Expression: GPL-3.0-or-later
@@ -14,11 +14,6 @@ from half_orm import utils
14
14
 
15
15
 
16
16
  @click.command()
17
- @click.option(
18
- '--prune-branches', '-p',
19
- is_flag=True,
20
- help='Also clean up local branches that no longer exist on remote'
21
- )
22
17
  @click.option(
23
18
  '--dry-run',
24
19
  is_flag=True,
@@ -29,7 +24,7 @@ from half_orm import utils
29
24
  is_flag=True,
30
25
  help='Show detailed information'
31
26
  )
32
- def check(prune_branches: bool, dry_run: bool, verbose: bool) -> None:
27
+ def check(dry_run: bool, verbose: bool) -> None:
33
28
  """
34
29
  Verify and update project configuration.
35
30
 
@@ -39,15 +34,12 @@ def check(prune_branches: bool, dry_run: bool, verbose: bool) -> None:
39
34
  Checks performed:
40
35
  • Git hooks are up to date (pre-commit)
41
36
  • Repository is properly configured
42
- Optionally: Clean up stale local branches
37
+ Detect and prompt to clean up stale local branches
43
38
 
44
39
  Examples:
45
40
  # Basic check and update
46
41
  half_orm dev check
47
42
 
48
- # Check and clean up stale branches
49
- half_orm dev check --prune-branches
50
-
51
43
  # Preview what would be done
52
44
  half_orm dev check --dry-run
53
45
  """
@@ -56,13 +48,12 @@ def check(prune_branches: bool, dry_run: bool, verbose: bool) -> None:
56
48
 
57
49
  # Perform check (delegates to Repo)
58
50
  result = repo.check_and_update(
59
- prune_branches=prune_branches,
60
51
  dry_run=dry_run,
61
52
  silent=False # Show messages
62
53
  )
63
54
 
64
55
  # Display results
65
- _display_check_results(result, dry_run, prune_branches, verbose)
56
+ _display_check_results(repo, result, dry_run, verbose)
66
57
 
67
58
  except Exception as e:
68
59
  click.echo(utils.Color.red(f"❌ Error: {e}"), err=True)
@@ -72,9 +63,9 @@ def check(prune_branches: bool, dry_run: bool, verbose: bool) -> None:
72
63
  raise click.Abort()
73
64
 
74
65
 
75
- def _display_check_results(result: dict, dry_run: bool, prune_branches: bool, verbose: bool):
66
+ def _display_check_results(repo, result: dict, dry_run: bool, verbose: bool):
76
67
  """Display check results to user."""
77
- # Version check
68
+ # Version check - display first and potentially interrupt
78
69
  version_info = result.get('version')
79
70
  if version_info:
80
71
  current = version_info.get('current_version')
@@ -86,8 +77,25 @@ def _display_check_results(result: dict, dry_run: bool, prune_branches: bool, ve
86
77
  if verbose:
87
78
  click.echo(f"ℹ {utils.Color.blue(f'Version check: {error}')}")
88
79
  elif update_available and latest:
89
- click.echo(f"⚠ {utils.Color.bold(f'half_orm_dev: {current}')} {utils.Color.bold(f'(update available: {latest})')}")
90
- click.echo(f" Run: {utils.Color.bold('pip install --upgrade half_orm_dev')}")
80
+ # Critical update notice - display prominently at the top
81
+ click.echo()
82
+ click.echo(f"{'='*70}")
83
+ click.echo(f"⚠️ {utils.Color.bold('UPDATE AVAILABLE')} ⚠️")
84
+ click.echo(f"{'='*70}")
85
+ click.echo(f"Current version: {utils.Color.bold(current)}")
86
+ click.echo(f"Latest version: {utils.Color.green(utils.Color.bold(latest))}")
87
+ click.echo()
88
+ click.echo(f"To update, run: {utils.Color.bold('pip install --upgrade half_orm_dev')}")
89
+ click.echo(f"{'='*70}")
90
+ click.echo()
91
+
92
+ # Prompt user to update now
93
+ if click.confirm("Do you want to interrupt and update now?", default=False):
94
+ click.echo(f"\nℹ️ Please run the following command:")
95
+ click.echo(f" {utils.Color.bold('pip install --upgrade half_orm_dev')}")
96
+ click.echo()
97
+ raise click.Abort()
98
+
91
99
  click.echo()
92
100
  elif current:
93
101
  click.echo(f"✓ {utils.Color.green(f'half_orm_dev: {current} (latest)')}")
@@ -139,30 +147,57 @@ def _display_check_results(result: dict, dry_run: bool, prune_branches: bool, ve
139
147
  if len(stale_release) > 5:
140
148
  click.echo(f" ... and {len(stale_release) - 5} more")
141
149
 
142
- # Prune results
143
- if prune_branches:
144
- branches = result.get('branches', {})
145
- deleted = branches.get('deleted', [])
146
-
147
- if deleted:
150
+ # Stale branches detection and cleanup
151
+ stale_branches = result.get('stale_branches', {})
152
+ candidates = stale_branches.get('candidates', [])
153
+
154
+ if candidates:
155
+ click.echo()
156
+ if dry_run:
157
+ click.echo(f"⚠️ {utils.Color.bold(f'Found {len(candidates)} stale local branch(es)')} (no longer on remote):")
158
+ for branch in candidates[:10]:
159
+ click.echo(f" ○ {branch}")
160
+ if len(candidates) > 10:
161
+ click.echo(f" ... and {len(candidates) - 10} more")
162
+ click.echo(f"\n Run without --dry-run to be prompted for deletion")
163
+ else:
164
+ # Show stale branches and prompt for deletion
165
+ click.echo(f"⚠️ {utils.Color.bold(f'Found {len(candidates)} stale local branch(es)')} (no longer on remote):")
166
+ for branch in candidates[:10]:
167
+ click.echo(f" • {branch}")
168
+ if len(candidates) > 10:
169
+ click.echo(f" ... and {len(candidates) - 10} more")
148
170
  click.echo()
149
- if dry_run:
150
- click.echo(f"○ {utils.Color.blue(f'Would delete {len(deleted)} stale branch(es)')}")
151
- else:
152
- click.echo(f"✓ {utils.Color.green(f'Deleted {len(deleted)} stale branch(es)')}")
153
171
 
154
- if verbose:
155
- for branch in deleted[:10]:
156
- symbol = "○" if dry_run else "✓"
157
- click.echo(f" {symbol} {branch}")
158
- if len(deleted) > 10:
159
- click.echo(f" ... and {len(deleted) - 10} more")
160
-
161
- if branches.get('errors'):
162
- click.echo(f"⚠ {utils.Color.red('Some errors occurred during cleanup')}")
163
- if verbose:
164
- for branch, error in branches['errors'][:3]:
165
- click.echo(f" {branch}: {error}")
172
+ # Prompt for confirmation
173
+ if click.confirm(f"Delete these {len(candidates)} branch(es)?", default=False):
174
+ # Get repo instance and actually delete the branches
175
+ deleted = []
176
+ errors = []
177
+ try:
178
+ delete_result = repo.hgit.prune_local_branches(
179
+ pattern="ho-*",
180
+ dry_run=False,
181
+ exclude_current=True
182
+ )
183
+ deleted = delete_result.get('deleted', [])
184
+ errors = delete_result.get('errors', [])
185
+
186
+ if deleted:
187
+ click.echo(f"\n✓ {utils.Color.green(f'Deleted {len(deleted)} stale branch(es)')}")
188
+ if verbose:
189
+ for branch in deleted[:10]:
190
+ click.echo(f" ✓ {branch}")
191
+ if len(deleted) > 10:
192
+ click.echo(f" ... and {len(deleted) - 10} more")
193
+
194
+ if errors:
195
+ click.echo(f"\n⚠ {utils.Color.red('Some errors occurred during cleanup')}")
196
+ if verbose:
197
+ for branch, error in errors[:3]:
198
+ click.echo(f" {branch}: {error}")
199
+ except Exception as e:
200
+ click.echo(utils.Color.red(f"\n❌ Error deleting branches: {e}"), err=True)
166
201
 
167
202
 
168
203
  def _display_release_branches_grouped(branches: list, verbose: bool):
@@ -263,10 +298,23 @@ def _display_releases_with_patches(releases_info: dict, patch_branches: list, re
263
298
  # Release header with status
264
299
  release_status = ""
265
300
  if release_branch_info:
266
- if not release_branch_info.get('exists_on_remote', False) and release_branch_info.get('exists_locally', False):
267
- release_status = f" {utils.Color.yellow('⚠️ local only - remote deleted')}"
268
- elif release_branch_info.get('sync_status') == 'remote_only':
301
+ sync_status = release_branch_info.get('sync_status', 'unknown')
302
+ ahead = release_branch_info.get('ahead', 0)
303
+ behind = release_branch_info.get('behind', 0)
304
+ exists_on_remote = release_branch_info.get('exists_on_remote', False)
305
+
306
+ if not exists_on_remote:
307
+ release_status = f" {utils.Color.bold('⚠️ local only - remote deleted')}"
308
+ elif sync_status == 'remote_only':
269
309
  release_status = f" {utils.Color.blue('☁️ on remote only')}"
310
+ elif sync_status == 'synced':
311
+ release_status = f" {utils.Color.green('✓ synced')}"
312
+ elif sync_status == 'ahead':
313
+ release_status = f" {utils.Color.blue(f'↑ {ahead} ahead')}"
314
+ elif sync_status == 'behind':
315
+ release_status = f" {utils.Color.blue(f'↓ {behind} behind')}"
316
+ elif sync_status == 'diverged':
317
+ release_status = f" {utils.Color.red(f'⚠ diverged (↑{ahead} ↓{behind})')}"
270
318
  else:
271
319
  # Release files exist but no branch at all
272
320
  release_status = f" {utils.Color.red('⚠️ branch not found')}"
@@ -303,7 +351,7 @@ def _display_releases_with_patches(releases_info: dict, patch_branches: list, re
303
351
  elif sync_status == 'diverged':
304
352
  status = utils.Color.red(f"⚠ diverged (↑{ahead} ↓{behind})")
305
353
  elif sync_status == 'no_remote':
306
- status = utils.Color.yellow("⚠️ local only (remote deleted or not pushed - run: git branch -d " + branch_name + ")")
354
+ status = utils.Color.bold("⚠️ local only (remote deleted or not pushed - run: git branch -d " + branch_name + ")")
307
355
  else:
308
356
  status = "?"
309
357
 
@@ -132,7 +132,7 @@ def _display_upgrade_results(result):
132
132
  final = result['final_version']
133
133
  click.echo(f"\nWould upgrade: {current} → {utils.Color.green(final)}")
134
134
 
135
- click.echo(f"\n{utils.Color.yellow('To apply this upgrade, run without --dry-run')}")
135
+ click.echo(f"\n{utils.Color.bold('To apply this upgrade, run without --dry-run')}")
136
136
  return
137
137
 
138
138
  # === ACTUAL UPGRADE ===
@@ -147,7 +147,7 @@ def _display_upgrade_results(result):
147
147
  # Up to date scenario
148
148
  pass
149
149
  else:
150
- click.echo(f"⚠️ {utils.Color.yellow('No backup created (--skip-backup used)')}")
150
+ click.echo(f"⚠️ {utils.Color.bold('No backup created (--skip-backup used)')}")
151
151
 
152
152
  click.echo(f"\nCurrent version: {utils.Color.bold(current)}")
153
153
 
@@ -2104,13 +2104,13 @@ class PatchManager:
2104
2104
 
2105
2105
  except FileNotFoundError:
2106
2106
  # pytest not installed - warn but don't block
2107
- click.echo(f" • {utils.Color.yellow('⚠')} pytest not found (install pytest to run tests)")
2107
+ click.echo(f" • {utils.Color.bold('⚠')} pytest not found (install pytest to run tests)")
2108
2108
  except PatchManagerError:
2109
2109
  # Re-raise our own exceptions (test failures)
2110
2110
  raise
2111
2111
  except Exception as e:
2112
2112
  # Any other error - warn but don't block (might be environment issue)
2113
- click.echo(f" • {utils.Color.yellow('⚠')} Failed to run tests: {e} (continuing anyway)")
2113
+ click.echo(f" • {utils.Color.bold('⚠')} Failed to run tests: {e} (continuing anyway)")
2114
2114
 
2115
2115
  # Note: KeyboardInterrupt (Ctrl+C) is not caught here - it inherits from
2116
2116
  # BaseException, not Exception, so it will propagate up to the decorator
@@ -110,10 +110,10 @@ class ReleaseManager:
110
110
  - X.Y.Z-hotfix[N].txt: Emergency hotfix (immutable)
111
111
 
112
112
  Examples:
113
- # Prepare new release
113
+ # Create new release
114
114
  release_mgr = ReleaseManager(repo)
115
- result = release_mgr.prepare_release('minor')
116
- # Creates releases/1.4.0-stage.txt
115
+ result = release_mgr.new_release('minor')
116
+ # Creates branch ho-release/1.4.0
117
117
 
118
118
  # Find latest version
119
119
  version = release_mgr.find_latest_version()
@@ -135,148 +135,6 @@ class ReleaseManager:
135
135
  self._base_dir = str(repo.base_dir)
136
136
  self._releases_dir = Path(repo.base_dir) / "releases"
137
137
 
138
- def prepare_release(self, increment_type: str) -> dict:
139
- """
140
- Prepare next release stage file.
141
-
142
- Creates new releases/X.Y.Z-stage.txt file based on latest version
143
- and increment type. Validates repository state, synchronizes with
144
- origin, and pushes to reserve version globally.
145
-
146
- Workflow:
147
- 0. Acquire lock tag
148
- 1. Validate on ho-prod branch
149
- 2. Validate repository is clean
150
- 3. Fetch from origin
151
- 4. Synchronize with origin/ho-prod (pull if behind)
152
- 5. Read production version from model/schema.sql
153
- 6. Calculate next version based on increment type
154
- 7. Verify stage file doesn't already exist
155
- 8. Create empty stage file
156
- 9. Commit with message "Prepare release X.Y.Z-stage"
157
- 10. Push to origin (global reservation)
158
- 11. Release lock tag
159
-
160
- Branch requirements:
161
- - Must be on ho-prod branch
162
- - Repository must be clean (no uncommitted changes)
163
- - Must be synced with origin/ho-prod (auto-pull if behind)
164
-
165
- Synchronization behavior:
166
- - "synced": Continue
167
- - "behind": Auto-pull with message
168
- - "ahead": Continue (will push at end)
169
- - "diverged": Error - manual merge required
170
-
171
- Args:
172
- increment_type: Version increment ("major", "minor", or "patch")
173
-
174
- Returns:
175
- dict: Preparation result with keys:
176
- - version: New version string (e.g., "1.4.0")
177
- - file: Path to created stage file
178
- - previous_version: Previous production version
179
-
180
- Raises:
181
- ReleaseManagerError: If validation fails
182
- ReleaseManagerError: If not on ho-prod branch
183
- ReleaseManagerError: If repository not clean
184
- ReleaseManagerError: If ho-prod diverged from origin
185
- ReleaseFileError: If stage file already exists
186
- ReleaseVersionError: If version calculation fails
187
-
188
- Examples:
189
- # Prepare minor release
190
- result = release_mgr.prepare_release('minor')
191
- # Production was 1.3.5 → creates releases/1.4.0-stage.txt
192
-
193
- # Prepare patch release
194
- result = release_mgr.prepare_release('patch')
195
- # Production was 1.3.5 → creates releases/1.3.6-stage.txt
196
-
197
- # Error handling
198
- try:
199
- result = release_mgr.prepare_release('major')
200
- except ReleaseManagerError as e:
201
- print(f"Failed: {e}")
202
- """
203
- try:
204
- # 0. ACQUIRE LOCK on ho-prod (with 30 min timeout for stale locks)
205
- lock_tag = self._repo.hgit.acquire_branch_lock("ho-prod", timeout_minutes=30)
206
-
207
- # 1. Validate on ho-prod branch
208
- if self._repo.hgit.branch != 'ho-prod':
209
- raise ReleaseManagerError(
210
- f"Must be on ho-prod branch to prepare release.\n"
211
- f"Current branch: {self._repo.hgit.branch}\n"
212
- f"Switch to ho-prod: git checkout ho-prod"
213
- )
214
-
215
- # 2. Validate repository is clean
216
- if not self._repo.hgit.repos_is_clean():
217
- raise ReleaseManagerError(
218
- "Repository has uncommitted changes.\n"
219
- "Commit or stash changes before preparing release:\n"
220
- " git status\n"
221
- " git add . && git commit"
222
- )
223
-
224
- # 3. Fetch from origin
225
- self._repo.hgit.fetch_from_origin()
226
-
227
- # 4. Synchronize with origin
228
- is_synced, status = self._repo.hgit.is_branch_synced("ho-prod")
229
-
230
- if status == "behind":
231
- # Pull automatically
232
- self._repo.hgit.pull()
233
- elif status == "diverged":
234
- raise ReleaseManagerError(
235
- "ho-prod has diverged from origin/ho-prod.\n"
236
- "Manual resolution required:\n"
237
- " git pull --rebase origin ho-prod\n"
238
- " or\n"
239
- " git merge origin/ho-prod"
240
- )
241
- # If "synced" or "ahead", continue
242
-
243
- # 5. Read production version from model/schema.sql
244
- prod_version_str = self._get_production_version()
245
-
246
- # Parse into Version object for calculation
247
- prod_version = self.parse_version_from_filename(f"{prod_version_str}.txt")
248
-
249
- # 6. Calculate next version
250
- next_version = self.calculate_next_version(prod_version, increment_type)
251
-
252
- # 7. Verify stage file doesn't exist
253
- stage_file = self._releases_dir / f"{next_version}-stage.txt"
254
- if stage_file.exists():
255
- raise ReleaseFileError(
256
- f"Stage file already exists: {stage_file}\n"
257
- f"Version {next_version} is already in development.\n"
258
- f"To continue with this version, use existing stage file."
259
- )
260
-
261
- # 8. Create empty stage file
262
- stage_file.touch()
263
-
264
- # 9. Commit
265
- self._repo.hgit.add(str(stage_file))
266
- self._repo.hgit.commit("-m", f"Prepare release {next_version}-stage")
267
-
268
- # 10. Push to origin (global reservation)
269
- self._repo.hgit.push()
270
- # Return result
271
- return {
272
- 'version': next_version,
273
- 'file': str(stage_file),
274
- 'previous_version': prod_version_str
275
- }
276
-
277
- finally:
278
- # 11. ALWAYS release lock (even on error)
279
- self._repo.hgit.release_branch_lock(lock_tag)
280
138
 
281
139
 
282
140
  def _get_production_version(self) -> str:
@@ -377,6 +235,199 @@ class ReleaseManager:
377
235
 
378
236
  return version
379
237
 
238
+ def _check_and_init_from_database(self) -> bool:
239
+ """
240
+ Check for legacy project and offer to initialize from database.
241
+
242
+ For projects migrating from half_orm 0.13.x, this method detects when ALL
243
+ three required elements are missing (releases/, model/, ho-prod branch) and
244
+ offers to initialize them from the existing database metadata.
245
+
246
+ Workflow:
247
+ 1. Check if releases/, model/, and ho-prod all exist
248
+ 2. If ALL THREE are missing, prompt user to initialize from database
249
+ 3. Create ho-prod branch from current branch
250
+ 4. Read production version from half_orm_meta.view.hop_last_release
251
+ 5. Create releases/ and model/ directories
252
+ 6. Generate schema and metadata files using Database._generate_schema_sql()
253
+ 7. Create releases/X.Y.Z.txt EMPTY (production release)
254
+ 8. Commit the initialization
255
+
256
+ Returns:
257
+ bool: True if initialization was performed, False if skipped
258
+
259
+ Raises:
260
+ ReleaseManagerError: If database not accessible or initialization fails
261
+ ReleaseManagerError: If project is in inconsistent state (only some parts exist)
262
+
263
+ Examples:
264
+ # Migrating old project
265
+ if release_mgr._check_and_init_from_database():
266
+ print("Initialized from database")
267
+ else:
268
+ print("Already initialized")
269
+ """
270
+ import click
271
+ from half_orm import utils
272
+
273
+ # Check what exists
274
+ releases_exists = self._releases_dir.exists()
275
+ model_dir = Path(self._base_dir) / "model"
276
+ model_exists = model_dir.exists() and (model_dir / "schema.sql").exists()
277
+ ho_prod_exists = self._repo.hgit.branch_exists("ho-prod")
278
+
279
+ # If all three exist, no initialization needed
280
+ if releases_exists and model_exists and ho_prod_exists:
281
+ return False
282
+
283
+ # If only some exist, the project is in an inconsistent state
284
+ if releases_exists or model_exists or ho_prod_exists:
285
+ missing = []
286
+ if not releases_exists:
287
+ missing.append("releases/")
288
+ if not model_exists:
289
+ missing.append("model/")
290
+ if not ho_prod_exists:
291
+ missing.append("ho-prod branch")
292
+
293
+ existing = []
294
+ if releases_exists:
295
+ existing.append("releases/")
296
+ if model_exists:
297
+ existing.append("model/")
298
+ if ho_prod_exists:
299
+ existing.append("ho-prod branch")
300
+
301
+ raise ReleaseManagerError(
302
+ f"Project in inconsistent state.\n\n"
303
+ f"Missing: {', '.join(missing)}\n"
304
+ f"Present: {', '.join(existing)}\n\n"
305
+ f"For legacy migration, all three must be missing.\n"
306
+ f"Please either:\n"
307
+ f" • Complete the setup manually, or\n"
308
+ f" • Remove existing elements to start fresh initialization"
309
+ )
310
+
311
+ # All three missing - offer to initialize from database
312
+ click.echo(f"\n{utils.Color.bold('⚠ Legacy project detected')}")
313
+ click.echo("The releases/ and model/ directories are missing.")
314
+ click.echo("This appears to be a project from half_orm 0.13.x or earlier.")
315
+ click.echo()
316
+ click.echo("I can initialize the workflow from your existing database:")
317
+ click.echo(" • Create ho-prod branch (if needed)")
318
+ click.echo(" • Read production version from half_orm_meta.view.hop_last_release")
319
+ click.echo(" • Create releases/ and model/ directories")
320
+ click.echo(" • Generate schema and metadata files")
321
+ click.echo(" • Create initial production release file")
322
+ click.echo()
323
+
324
+ if not click.confirm("Initialize from database?", default=True):
325
+ raise ReleaseManagerError(
326
+ "Cannot proceed without releases/ directory.\n"
327
+ "Run this command again and confirm initialization, or\n"
328
+ "manually create the releases/ directory structure."
329
+ )
330
+
331
+ try:
332
+ # Ensure ho-prod branch exists
333
+ click.echo(f"\n{utils.Color.bold('Checking ho-prod branch...')}")
334
+ current_branch = self._repo.hgit.branch
335
+ if not self._repo.hgit.branch_exists("ho-prod"):
336
+ click.echo(f" Creating ho-prod branch from {current_branch}...")
337
+ try:
338
+ self._repo.hgit.create_branch("ho-prod", from_branch=current_branch)
339
+ click.echo(f" {utils.Color.green('✓ Created ho-prod branch')}")
340
+
341
+ # Push to remote if exists
342
+ if self._repo.hgit.has_remote():
343
+ self._repo.hgit.push_branch("ho-prod")
344
+ click.echo(f" {utils.Color.green('✓ Pushed ho-prod to origin')}")
345
+
346
+ # Switch to ho-prod
347
+ self._repo.hgit.checkout("ho-prod")
348
+ except Exception as e:
349
+ raise ReleaseManagerError(f"Failed to create ho-prod branch: {e}")
350
+ else:
351
+ click.echo(f" ✓ ho-prod branch already exists")
352
+ # Switch to ho-prod
353
+ if current_branch != "ho-prod":
354
+ self._repo.hgit.checkout("ho-prod")
355
+
356
+ # Read production version from database
357
+ click.echo(f"\n{utils.Color.bold('Reading version from database...')}")
358
+ try:
359
+ version_str = self._repo.database.last_release_s
360
+ click.echo(f" Production version: {utils.Color.green(version_str)}")
361
+ except Exception as e:
362
+ raise ReleaseManagerError(
363
+ f"Failed to read version from database.\n"
364
+ f"Error: {e}\n\n"
365
+ f"Ensure:\n"
366
+ f" • Database is accessible\n"
367
+ f" • half_orm_meta schema exists\n"
368
+ f" • hop_last_release view is populated"
369
+ )
370
+
371
+ # Create directories
372
+ click.echo(f"\n{utils.Color.bold('Creating directories...')}")
373
+ self._releases_dir.mkdir(parents=True, exist_ok=True)
374
+ click.echo(f" ✓ Created {self._releases_dir}")
375
+ model_dir.mkdir(parents=True, exist_ok=True)
376
+ click.echo(f" ✓ Created {model_dir}")
377
+
378
+ # Generate schema and metadata files using existing method
379
+ click.echo(f"\n{utils.Color.bold('Generating schema files...')}")
380
+ try:
381
+ self._repo.database._generate_schema_sql(version_str, model_dir)
382
+ click.echo(f" ✓ Generated schema-{version_str}.sql")
383
+ click.echo(f" ✓ Generated metadata-{version_str}.sql")
384
+ click.echo(f" ✓ Created symlink: schema.sql -> schema-{version_str}.sql")
385
+ except Exception as e:
386
+ raise ReleaseManagerError(f"Failed to generate schema files: {e}")
387
+
388
+ # Create empty production release file
389
+ click.echo(f"\n{utils.Color.bold('Creating release file...')}")
390
+ release_file = self._releases_dir / f"{version_str}.txt"
391
+ release_file.touch()
392
+ click.echo(f" ✓ Created {release_file.name} (empty - production release)")
393
+
394
+ # Commit initialization
395
+ click.echo(f"\n{utils.Color.bold('Committing initialization...')}")
396
+ self._repo.hgit.add(str(self._releases_dir))
397
+ self._repo.hgit.add(str(model_dir))
398
+ self._repo.hgit.commit(
399
+ "-m",
400
+ f"chore: initialize releases/ and model/ from database (version {version_str})"
401
+ )
402
+ click.echo(f" {utils.Color.green('✓ Initialization committed')}")
403
+
404
+ # Create production tag for this version
405
+ click.echo(f"\n{utils.Color.bold('Creating production tag...')}")
406
+ prod_tag = f"v{version_str}"
407
+ self._repo.hgit.create_tag(prod_tag, f"Production release {version_str} (migrated from database)")
408
+ click.echo(f" {utils.Color.green(f'✓ Created tag {prod_tag}')}")
409
+
410
+ # Push if remote exists
411
+ if self._repo.hgit.has_remote():
412
+ click.echo(f"\n{utils.Color.bold('Pushing to origin...')}")
413
+ self._repo.hgit.push()
414
+ self._repo.hgit.push_tag(prod_tag)
415
+ click.echo(f" {utils.Color.green('✓ Pushed to origin')}")
416
+ click.echo(f" {utils.Color.green(f'✓ Pushed tag {prod_tag}')}")
417
+
418
+ click.echo(f"\n{utils.Color.green('✓ Successfully initialized from database!')}")
419
+ click.echo()
420
+
421
+ return True
422
+
423
+ except Exception as e:
424
+ # Clean up on error
425
+ if self._releases_dir.exists() and not any(self._releases_dir.iterdir()):
426
+ self._releases_dir.rmdir()
427
+ if model_dir.exists() and not any(model_dir.iterdir()):
428
+ model_dir.rmdir()
429
+ raise ReleaseManagerError(f"Initialization failed: {e}")
430
+
380
431
  def find_latest_version(self) -> Optional[Version]:
381
432
  """
382
433
  Find latest version across all release stages.
@@ -2365,6 +2416,7 @@ class ReleaseManager:
2365
2416
  dependencies between patches.
2366
2417
 
2367
2418
  Workflow:
2419
+ 0. Check for legacy project migration (auto-initialize if needed)
2368
2420
  1. Calculate next version based on level (major/minor/patch)
2369
2421
  2. Create release branch ho-release/{version} from ho-prod
2370
2422
  3. Push release branch to remote
@@ -2393,15 +2445,19 @@ class ReleaseManager:
2393
2445
  # → Creates empty 0.1.0-stage.txt
2394
2446
  # → Switches to ho-release/0.1.0
2395
2447
  """
2448
+ # 0. Check for legacy project migration
2449
+ was_initialized = self._check_and_init_from_database()
2450
+
2396
2451
  # Calculate next version
2397
2452
  version = self._calculate_next_version(level)
2398
2453
  release_branch = f"ho-release/{version}"
2399
2454
 
2400
- # Ensure we're on ho-prod
2401
- try:
2402
- self._repo.hgit.checkout("ho-prod")
2403
- except Exception as e:
2404
- raise ReleaseManagerError(f"Failed to checkout ho-prod: {e}")
2455
+ # Ensure we're on ho-prod (unless we just initialized and are already there)
2456
+ if not was_initialized or self._repo.hgit.branch != "ho-prod":
2457
+ try:
2458
+ self._repo.hgit.checkout("ho-prod")
2459
+ except Exception as e:
2460
+ raise ReleaseManagerError(f"Failed to checkout ho-prod: {e}")
2405
2461
 
2406
2462
  # Create empty candidates file (NEW)
2407
2463
  candidates_file = self._releases_dir / f"{version}-candidates.txt"