half-orm-dev 0.17.0a7__tar.gz → 0.17.0a10__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-0.17.0a7/half_orm_dev.egg-info → half_orm_dev-0.17.0a10}/PKG-INFO +1 -1
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/check.py +24 -6
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/patch.py +2 -102
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/release.py +4 -5
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/decorators.py +16 -11
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/hgit.py +68 -10
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/patch_manager.py +91 -24
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/release_manager.py +112 -31
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/repo.py +51 -39
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/utils.py +0 -13
- half_orm_dev-0.17.0a10/half_orm_dev/version.txt +1 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10/half_orm_dev.egg-info}/PKG-INFO +1 -1
- half_orm_dev-0.17.0a7/half_orm_dev/version.txt +0 -1
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/AUTHORS +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/LICENSE +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/README.md +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/__init__.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/__init__.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/__init__.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/apply.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/clone.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/init.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/new.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/restore.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/sync.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/todo.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/undo.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/update.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/commands/upgrade.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli/main.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/cli_extension.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/database.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/hop.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/manifest.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/modules.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/patch.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/patch_validator.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/patches/log +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/.gitignore +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/MANIFEST.in +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/Pipfile +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/README +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/conftest_template +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/init_module_template +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/module_template_1 +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/module_template_2 +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/module_template_3 +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/relation_test +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/setup.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/sql_adapter +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/warning +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev.egg-info/SOURCES.txt +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev.egg-info/dependency_links.txt +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev.egg-info/requires.txt +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev.egg-info/top_level.txt +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/setup.cfg +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/setup.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/tests/__init__.py +0 -0
- {half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/tests/conftest.py +0 -0
|
@@ -97,7 +97,7 @@ def _display_check_results(result: dict, dry_run: bool, prune_branches: bool, ve
|
|
|
97
97
|
# Show releases with candidates and staged patches
|
|
98
98
|
releases_info = result.get('releases_info', {})
|
|
99
99
|
if releases_info:
|
|
100
|
-
_display_releases_with_patches(releases_info, patch_branches, verbose)
|
|
100
|
+
_display_releases_with_patches(releases_info, patch_branches, release_branches, verbose)
|
|
101
101
|
elif verbose:
|
|
102
102
|
click.echo(f"\n📦 {utils.Color.bold('Active releases:')} None")
|
|
103
103
|
|
|
@@ -220,12 +220,13 @@ def _display_branch_info(branch_info: dict, verbose: bool, indent: str = " ", s
|
|
|
220
220
|
click.echo(f"{indent}{marker}• {display_name} - {status}")
|
|
221
221
|
|
|
222
222
|
|
|
223
|
-
def _display_releases_with_patches(releases_info: dict, patch_branches: list, verbose: bool):
|
|
223
|
+
def _display_releases_with_patches(releases_info: dict, patch_branches: list, release_branches: list, verbose: bool):
|
|
224
224
|
"""Display releases grouped by version with candidates and staged patches.
|
|
225
225
|
|
|
226
226
|
Args:
|
|
227
227
|
releases_info: Dict of {version: {candidates: [], staged: [], ...}}
|
|
228
228
|
patch_branches: List of patch branch info dicts
|
|
229
|
+
release_branches: List of release branch info dicts
|
|
229
230
|
verbose: Show verbose output
|
|
230
231
|
"""
|
|
231
232
|
# Sort versions
|
|
@@ -236,9 +237,22 @@ def _display_releases_with_patches(releases_info: dict, patch_branches: list, ve
|
|
|
236
237
|
candidates = info.get('candidates', [])
|
|
237
238
|
staged = info.get('staged', [])
|
|
238
239
|
|
|
239
|
-
#
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
# Check if release branch exists
|
|
241
|
+
release_branch_name = f"ho-release/{version}"
|
|
242
|
+
release_branch_info = next((b for b in release_branches if b['name'] == release_branch_name), None)
|
|
243
|
+
|
|
244
|
+
# Release header with status
|
|
245
|
+
release_status = ""
|
|
246
|
+
if release_branch_info:
|
|
247
|
+
if not release_branch_info.get('exists_on_remote', False) and release_branch_info.get('exists_locally', False):
|
|
248
|
+
release_status = f" {utils.Color.yellow('⚠️ local only - remote deleted')}"
|
|
249
|
+
elif release_branch_info.get('sync_status') == 'remote_only':
|
|
250
|
+
release_status = f" {utils.Color.blue('☁️ on remote only')}"
|
|
251
|
+
else:
|
|
252
|
+
# Release files exist but no branch at all
|
|
253
|
+
release_status = f" {utils.Color.red('⚠️ branch not found')}"
|
|
254
|
+
|
|
255
|
+
click.echo(f"\n📦 {utils.Color.bold(f'Release {version}')} (ho-release/{version}):{release_status}")
|
|
242
256
|
|
|
243
257
|
# Show staged patches
|
|
244
258
|
if staged:
|
|
@@ -261,18 +275,22 @@ def _display_releases_with_patches(releases_info: dict, patch_branches: list, ve
|
|
|
261
275
|
|
|
262
276
|
if sync_status == 'synced':
|
|
263
277
|
status = utils.Color.green("✓ synced")
|
|
278
|
+
elif sync_status == 'remote_only':
|
|
279
|
+
status = utils.Color.blue("☁️ on remote only (run: git checkout -b ho-patch/" + patch_id + " origin/ho-patch/" + patch_id + ")")
|
|
264
280
|
elif sync_status == 'behind':
|
|
265
281
|
status = utils.Color.blue(f"⚠️ {behind} commits behind")
|
|
266
282
|
elif sync_status == 'ahead':
|
|
267
283
|
status = utils.Color.blue(f"↑ {ahead} ahead")
|
|
268
284
|
elif sync_status == 'diverged':
|
|
269
285
|
status = utils.Color.red(f"⚠ diverged (↑{ahead} ↓{behind})")
|
|
286
|
+
elif sync_status == 'no_remote':
|
|
287
|
+
status = utils.Color.yellow("⚠️ local only (remote deleted or not pushed - run: git branch -d " + branch_name + ")")
|
|
270
288
|
else:
|
|
271
289
|
status = "?"
|
|
272
290
|
|
|
273
291
|
click.echo(f" • {patch_id} - {status}")
|
|
274
292
|
else:
|
|
275
|
-
# Branch doesn't exist
|
|
293
|
+
# Branch doesn't exist anywhere
|
|
276
294
|
click.echo(f" • {patch_id} {utils.Color.red('⚠ branch not found')}")
|
|
277
295
|
|
|
278
296
|
if not staged and not candidates:
|
|
@@ -4,7 +4,7 @@ Patch command group - Unified patch development and management.
|
|
|
4
4
|
Groups all patch-related commands under 'half_orm dev patch':
|
|
5
5
|
- patch new: Create new patch branch and directory
|
|
6
6
|
- patch apply: Apply current patch files to database
|
|
7
|
-
- patch
|
|
7
|
+
- patch close: Add patch to stage release with validation
|
|
8
8
|
|
|
9
9
|
Replaces legacy commands:
|
|
10
10
|
- create-patch → patch new
|
|
@@ -33,7 +33,7 @@ def patch():
|
|
|
33
33
|
Common workflow:
|
|
34
34
|
1. half_orm dev patch new <patch_id>
|
|
35
35
|
2. half_orm dev patch apply
|
|
36
|
-
3. half_orm dev patch
|
|
36
|
+
3. half_orm dev patch close <patch_id>
|
|
37
37
|
"""
|
|
38
38
|
pass
|
|
39
39
|
|
|
@@ -288,103 +288,3 @@ def patch_close(patch_id: str) -> None:
|
|
|
288
288
|
|
|
289
289
|
except PatchManagerError as e:
|
|
290
290
|
raise click.ClickException(str(e))
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
@patch.command('add')
|
|
294
|
-
@click.argument('patch_id', type=str)
|
|
295
|
-
@click.option(
|
|
296
|
-
'--to-version', '-v',
|
|
297
|
-
type=str,
|
|
298
|
-
default=None,
|
|
299
|
-
help='Target release version (required if multiple stage releases exist)'
|
|
300
|
-
)
|
|
301
|
-
def patch_add(patch_id: str, to_version: Optional[str] = None) -> None:
|
|
302
|
-
"""
|
|
303
|
-
Add patch to stage release file with validation.
|
|
304
|
-
|
|
305
|
-
Integrates developed patch into a stage release for deployment.
|
|
306
|
-
Must be run from ho-prod branch. All business logic is delegated
|
|
307
|
-
to ReleaseManager with distributed lock for safe concurrent operations.
|
|
308
|
-
|
|
309
|
-
\b
|
|
310
|
-
Complete workflow:
|
|
311
|
-
1. Acquire exclusive lock on ho-prod (via Git tag)
|
|
312
|
-
2. Create temporary validation branch
|
|
313
|
-
3. Apply all release patches + current patch
|
|
314
|
-
4. Run pytest validation tests
|
|
315
|
-
5. If tests pass: integrate to ho-prod
|
|
316
|
-
6. If tests fail: cleanup and exit with error
|
|
317
|
-
7. Send resync notifications to other patch branches
|
|
318
|
-
8. Archive patch branch to ho-release/{version}/ namespace
|
|
319
|
-
9. Cleanup patch branch
|
|
320
|
-
10. Release lock
|
|
321
|
-
|
|
322
|
-
\b
|
|
323
|
-
Args:
|
|
324
|
-
patch_id: Patch identifier to add (e.g., "456-user-auth")
|
|
325
|
-
to_version: Target release version (auto-detected if single stage exists)
|
|
326
|
-
|
|
327
|
-
\b
|
|
328
|
-
Branch Requirements:
|
|
329
|
-
- Must be on ho-prod branch
|
|
330
|
-
- Repository must be clean (no uncommitted changes)
|
|
331
|
-
- Must be synced with origin/ho-prod
|
|
332
|
-
- Patch branch ho-patch/PATCH_ID must exist
|
|
333
|
-
- At least one stage release file must exist
|
|
334
|
-
|
|
335
|
-
\b
|
|
336
|
-
Examples:
|
|
337
|
-
Add patch to auto-detected stage release:
|
|
338
|
-
$ half_orm dev patch add 456-user-auth
|
|
339
|
-
|
|
340
|
-
Add patch to specific version:
|
|
341
|
-
$ half_orm dev patch add 456-user-auth --to-version 1.3.6
|
|
342
|
-
|
|
343
|
-
\b
|
|
344
|
-
Output:
|
|
345
|
-
✓ Detected stage release: 1.3.6-stage.txt
|
|
346
|
-
✓ Validated patch 456-user-auth
|
|
347
|
-
✓ All tests passed
|
|
348
|
-
✓ Integrated to ho-prod
|
|
349
|
-
✓ Archived branch: ho-release/1.3.6/456-user-auth
|
|
350
|
-
✓ Notified 2 active patch branches
|
|
351
|
-
|
|
352
|
-
📝 Next steps:
|
|
353
|
-
1. Other developers: git pull && git rebase ho-prod
|
|
354
|
-
2. Continue development: half_orm dev patch new <next_patch_id>
|
|
355
|
-
3. Promote to RC: half_orm dev release promote rc
|
|
356
|
-
|
|
357
|
-
\b
|
|
358
|
-
Raises:
|
|
359
|
-
click.ClickException: If validation fails or integration errors occur
|
|
360
|
-
"""
|
|
361
|
-
try:
|
|
362
|
-
# Get repository instance
|
|
363
|
-
repo = Repo()
|
|
364
|
-
|
|
365
|
-
# Display context
|
|
366
|
-
click.echo(f"Adding patch {utils.Color.bold(patch_id)} to stage release...")
|
|
367
|
-
click.echo()
|
|
368
|
-
|
|
369
|
-
# Delegate to ReleaseManager
|
|
370
|
-
result = repo.release_manager.add_patch_to_release(patch_id, to_version)
|
|
371
|
-
|
|
372
|
-
# Display success message
|
|
373
|
-
click.echo(f"✓ {utils.Color.green('Patch added to release successfully!')}")
|
|
374
|
-
click.echo()
|
|
375
|
-
click.echo(f" Stage file: {utils.Color.bold(result['stage_file'])}")
|
|
376
|
-
click.echo(f" Patch added: {utils.Color.bold(result['patch_id'])}")
|
|
377
|
-
click.echo(f" Tests passed: {utils.Color.green('✓')}")
|
|
378
|
-
|
|
379
|
-
if result.get('notified_branches'):
|
|
380
|
-
click.echo(f" Notified: {len(result['notified_branches'])} active branch(es)")
|
|
381
|
-
|
|
382
|
-
click.echo()
|
|
383
|
-
click.echo("📝 Next steps:")
|
|
384
|
-
click.echo(f" • Other developers: {utils.Color.bold('git pull && git rebase ho-prod')}")
|
|
385
|
-
click.echo(f" • Continue development: {utils.Color.bold('half_orm dev patch new <next_patch_id>')}")
|
|
386
|
-
click.echo(f" • Promote to RC: {utils.Color.bold('half_orm dev release promote rc')}")
|
|
387
|
-
click.echo()
|
|
388
|
-
|
|
389
|
-
except ReleaseManagerError as e:
|
|
390
|
-
raise click.ClickException(str(e))
|
|
@@ -345,13 +345,12 @@ def release_hotfix(version: Optional[str] = None) -> None:
|
|
|
345
345
|
|
|
346
346
|
# Display context
|
|
347
347
|
if version:
|
|
348
|
-
click.echo(f"
|
|
349
|
-
|
|
350
|
-
click.echo("Reopening current production version for hotfix...")
|
|
348
|
+
click.echo(f"⚠️ Version parameter is ignored - will reopen current production version")
|
|
349
|
+
click.echo("Reopening current production version for hotfix...")
|
|
351
350
|
click.echo()
|
|
352
351
|
|
|
353
|
-
# Delegate to ReleaseManager
|
|
354
|
-
result = repo.release_manager.reopen_for_hotfix(
|
|
352
|
+
# Delegate to ReleaseManager (auto-detects version)
|
|
353
|
+
result = repo.release_manager.reopen_for_hotfix()
|
|
355
354
|
|
|
356
355
|
# Display success message
|
|
357
356
|
click.echo(f"✓ {utils.Color.green('Version reopened for hotfix successfully!')}")
|
|
@@ -7,34 +7,39 @@ Provides common decorators for ReleaseManager and PatchManager.
|
|
|
7
7
|
from functools import wraps
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
|
|
11
11
|
"""
|
|
12
|
-
Decorator to protect methods
|
|
12
|
+
Decorator to protect methods with a dynamic branch lock.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
Unlike with_branch_lock which uses a static branch name, this decorator
|
|
15
|
+
calls a function to determine the branch name at runtime.
|
|
16
16
|
|
|
17
17
|
Args:
|
|
18
|
-
|
|
18
|
+
branch_getter: Callable that takes (self, *args, **kwargs) and returns branch name
|
|
19
19
|
timeout_minutes: Lock timeout in minutes (default: 30)
|
|
20
20
|
|
|
21
21
|
Usage:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
def _get_release_branch(self, patch_id, *args, **kwargs):
|
|
23
|
+
# Logic to determine release branch from patch_id
|
|
24
|
+
return f"ho-release/{version}"
|
|
25
|
+
|
|
26
|
+
@with_dynamic_branch_lock(_get_release_branch)
|
|
27
|
+
def close_patch(self, patch_id):
|
|
28
|
+
# Will lock the release branch determined by _get_release_branch
|
|
25
29
|
...
|
|
26
30
|
|
|
27
31
|
Notes:
|
|
28
|
-
-
|
|
29
|
-
and `release_branch_lock()` methods
|
|
32
|
+
- branch_getter is called with the same arguments as the decorated function
|
|
30
33
|
- The lock is ALWAYS released in the finally block, even on error
|
|
31
|
-
- If lock acquisition fails, the method is not executed
|
|
32
34
|
"""
|
|
33
35
|
def decorator(func):
|
|
34
36
|
@wraps(func)
|
|
35
37
|
def wrapper(self, *args, **kwargs):
|
|
36
38
|
lock_tag = None
|
|
37
39
|
try:
|
|
40
|
+
# Determine branch name dynamically
|
|
41
|
+
branch = branch_getter(self, *args, **kwargs)
|
|
42
|
+
|
|
38
43
|
# Acquire lock
|
|
39
44
|
lock_tag = self._repo.hgit.acquire_branch_lock(branch, timeout_minutes=timeout_minutes)
|
|
40
45
|
|
|
@@ -255,6 +255,32 @@ class HGit:
|
|
|
255
255
|
"Proxy to git.commit method"
|
|
256
256
|
return self.__git_repo.git.checkout(*args, **kwargs)
|
|
257
257
|
|
|
258
|
+
def checkout_paths_from_branch(self, branch: str, paths: list) -> None:
|
|
259
|
+
"""
|
|
260
|
+
Checkout specific paths from another branch into working directory.
|
|
261
|
+
|
|
262
|
+
This allows selective extraction of files/directories from a branch
|
|
263
|
+
without merging the entire branch. Used to sync metadata files
|
|
264
|
+
(like releases/*.txt) between branches.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
branch: Source branch name (e.g., "ho-release/0.1.0")
|
|
268
|
+
paths: List of paths to checkout (e.g., ["releases/", "docs/"])
|
|
269
|
+
|
|
270
|
+
Examples:
|
|
271
|
+
# Copy only releases/ directory from release branch to current branch
|
|
272
|
+
hgit.checkout("ho-prod")
|
|
273
|
+
hgit.checkout_paths_from_branch("ho-release/0.1.0", ["releases/"])
|
|
274
|
+
# Now releases/ contains files from ho-release/0.1.0
|
|
275
|
+
hgit.commit("-m", "sync releases/ from ho-release/0.1.0")
|
|
276
|
+
|
|
277
|
+
Notes:
|
|
278
|
+
- Changes are staged automatically
|
|
279
|
+
- Current branch must be checked out first
|
|
280
|
+
- Paths are relative to repository root
|
|
281
|
+
"""
|
|
282
|
+
self.__git_repo.git.checkout(branch, '--', *paths)
|
|
283
|
+
|
|
258
284
|
def pull(self, *args, **kwargs):
|
|
259
285
|
"Proxy to git.pull method"
|
|
260
286
|
return self.__git_repo.git.pull(*args, **kwargs)
|
|
@@ -411,12 +437,13 @@ class HGit:
|
|
|
411
437
|
|
|
412
438
|
def fetch_from_origin(self) -> None:
|
|
413
439
|
"""
|
|
414
|
-
Fetch all references from origin remote.
|
|
440
|
+
Fetch all references from origin remote with pruning.
|
|
415
441
|
|
|
416
442
|
Updates local knowledge of all remote references including:
|
|
417
443
|
- All remote branches
|
|
418
444
|
- All remote tags
|
|
419
445
|
- Other remote refs
|
|
446
|
+
- Removes stale remote-tracking references (--prune)
|
|
420
447
|
|
|
421
448
|
This is more comprehensive than fetch_tags() which only fetches tags.
|
|
422
449
|
Used before patch creation to ensure up-to-date view of remote state.
|
|
@@ -427,10 +454,11 @@ class HGit:
|
|
|
427
454
|
Examples:
|
|
428
455
|
hgit.fetch_from_origin()
|
|
429
456
|
# Local git now has complete up-to-date view of origin
|
|
457
|
+
# Stale remote refs (deleted branches on remote) are removed
|
|
430
458
|
"""
|
|
431
459
|
try:
|
|
432
460
|
origin = self.__git_repo.remote('origin')
|
|
433
|
-
origin.fetch()
|
|
461
|
+
origin.fetch(prune=True)
|
|
434
462
|
except Exception as e:
|
|
435
463
|
from git.exc import GitCommandError
|
|
436
464
|
if isinstance(e, GitCommandError):
|
|
@@ -1447,11 +1475,19 @@ class HGit:
|
|
|
1447
1475
|
remote_branches = self.get_remote_branches()
|
|
1448
1476
|
remote_branch_names = {b.replace('origin/', '') for b in remote_branches}
|
|
1449
1477
|
|
|
1450
|
-
# Get ho-patch branches
|
|
1451
|
-
|
|
1478
|
+
# Get ho-patch branches (local + remote)
|
|
1479
|
+
local_patch_branches = self.get_local_branches(pattern="ho-patch/*")
|
|
1480
|
+
remote_patch_branches = [b.replace('origin/', '') for b in remote_branches
|
|
1481
|
+
if b.startswith('origin/ho-patch/')]
|
|
1482
|
+
# Combine local and remote, avoiding duplicates
|
|
1483
|
+
all_patch_branches = list(set(local_patch_branches + remote_patch_branches))
|
|
1452
1484
|
|
|
1453
|
-
# Get ho-release branches
|
|
1454
|
-
|
|
1485
|
+
# Get ho-release branches (local + remote)
|
|
1486
|
+
local_release_branches = self.get_local_branches(pattern="ho-release/*")
|
|
1487
|
+
remote_release_branches = [b.replace('origin/', '') for b in remote_branches
|
|
1488
|
+
if b.startswith('origin/ho-release/')]
|
|
1489
|
+
# Combine local and remote, avoiding duplicates
|
|
1490
|
+
all_release_branches = list(set(local_release_branches + remote_release_branches))
|
|
1455
1491
|
|
|
1456
1492
|
# Parse stage files to identify active release branches and their order
|
|
1457
1493
|
active_release_patches = set()
|
|
@@ -1477,11 +1513,18 @@ class HGit:
|
|
|
1477
1513
|
except Exception:
|
|
1478
1514
|
pass
|
|
1479
1515
|
|
|
1480
|
-
def get_branch_info(branch: str, check_stage: bool = False) -> dict:
|
|
1481
|
-
"""Helper to get branch status.
|
|
1516
|
+
def get_branch_info(branch: str, check_stage: bool = False, is_local: bool = True) -> dict:
|
|
1517
|
+
"""Helper to get branch status.
|
|
1518
|
+
|
|
1519
|
+
Args:
|
|
1520
|
+
branch: Branch name
|
|
1521
|
+
check_stage: Whether to check stage file membership
|
|
1522
|
+
is_local: Whether branch exists locally
|
|
1523
|
+
"""
|
|
1482
1524
|
info = {
|
|
1483
1525
|
'name': branch,
|
|
1484
1526
|
'is_current': branch == current_branch,
|
|
1527
|
+
'exists_locally': is_local,
|
|
1485
1528
|
'exists_on_remote': branch in remote_branch_names,
|
|
1486
1529
|
'sync_status': 'unknown',
|
|
1487
1530
|
'ahead': 0,
|
|
@@ -1508,9 +1551,13 @@ class HGit:
|
|
|
1508
1551
|
info['in_stage_file'] = False
|
|
1509
1552
|
info['order'] = 999
|
|
1510
1553
|
|
|
1554
|
+
# Determine sync status
|
|
1511
1555
|
if not info['exists_on_remote']:
|
|
1512
1556
|
info['sync_status'] = 'no_remote'
|
|
1557
|
+
elif not info['exists_locally']:
|
|
1558
|
+
info['sync_status'] = 'remote_only'
|
|
1513
1559
|
else:
|
|
1560
|
+
# Both local and remote exist - check sync
|
|
1514
1561
|
try:
|
|
1515
1562
|
is_synced, status = self.is_branch_synced(branch, remote='origin')
|
|
1516
1563
|
info['sync_status'] = status
|
|
@@ -1535,8 +1582,19 @@ class HGit:
|
|
|
1535
1582
|
|
|
1536
1583
|
return info
|
|
1537
1584
|
|
|
1585
|
+
# Build branch info with local/remote indication
|
|
1586
|
+
patch_branch_infos = []
|
|
1587
|
+
for branch in all_patch_branches:
|
|
1588
|
+
is_local = branch in local_patch_branches
|
|
1589
|
+
patch_branch_infos.append(get_branch_info(branch, is_local=is_local))
|
|
1590
|
+
|
|
1591
|
+
release_branch_infos = []
|
|
1592
|
+
for branch in all_release_branches:
|
|
1593
|
+
is_local = branch in local_release_branches
|
|
1594
|
+
release_branch_infos.append(get_branch_info(branch, check_stage=True, is_local=is_local))
|
|
1595
|
+
|
|
1538
1596
|
return {
|
|
1539
1597
|
'current_branch': current_branch,
|
|
1540
|
-
'patch_branches':
|
|
1541
|
-
'release_branches':
|
|
1598
|
+
'patch_branches': patch_branch_infos,
|
|
1599
|
+
'release_branches': release_branch_infos
|
|
1542
1600
|
}
|
|
@@ -20,6 +20,7 @@ from git.exc import GitCommandError
|
|
|
20
20
|
from half_orm import utils
|
|
21
21
|
from half_orm_dev import modules
|
|
22
22
|
from .patch_validator import PatchValidator, PatchInfo
|
|
23
|
+
from .decorators import with_dynamic_branch_lock
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class PatchManagerError(Exception):
|
|
@@ -1227,6 +1228,7 @@ class PatchManager:
|
|
|
1227
1228
|
# Directory deletion may fail (permissions, etc.) - continue
|
|
1228
1229
|
pass
|
|
1229
1230
|
|
|
1231
|
+
@with_dynamic_branch_lock(lambda self, patch_id, description=None: "ho-prod")
|
|
1230
1232
|
def create_patch(self, patch_id: str, description: Optional[str] = None) -> dict:
|
|
1231
1233
|
"""
|
|
1232
1234
|
Create new patch with atomic tag-first reservation strategy.
|
|
@@ -1286,7 +1288,8 @@ class PatchManager:
|
|
|
1286
1288
|
# With description for README and commits
|
|
1287
1289
|
"""
|
|
1288
1290
|
# Step 1-3: Validate context
|
|
1289
|
-
|
|
1291
|
+
release_branch = self._validate_on_ho_release()
|
|
1292
|
+
release_version = release_branch.replace("ho-release/", "")
|
|
1290
1293
|
self._validate_repo_clean()
|
|
1291
1294
|
self._validate_has_remote()
|
|
1292
1295
|
|
|
@@ -1299,16 +1302,12 @@ class PatchManager:
|
|
|
1299
1302
|
lock_tag = None
|
|
1300
1303
|
# Save initial state for rollback
|
|
1301
1304
|
initial_branch = self._repo.hgit.branch # Should be ho-release/X.Y.Z
|
|
1302
|
-
release_branch = f"ho-release/{release_version}"
|
|
1303
1305
|
patch_dir = None
|
|
1304
1306
|
commit_created = False
|
|
1305
1307
|
tag_pushed = False
|
|
1306
1308
|
modifications_started = False # Track if we started making changes
|
|
1307
1309
|
branch_name = f"ho-patch/{normalized_id}"
|
|
1308
1310
|
try:
|
|
1309
|
-
# Step 5: ACQUIRE LOCK on ho-release/X.Y.Z (with 30 min timeout for stale locks)
|
|
1310
|
-
lock_tag = self._repo.hgit.acquire_branch_lock(release_branch, timeout_minutes=30)
|
|
1311
|
-
|
|
1312
1311
|
# Step 6: Fetch all references from remote (branches + tags) - with lock held
|
|
1313
1312
|
self._fetch_from_remote()
|
|
1314
1313
|
|
|
@@ -1347,6 +1346,9 @@ class PatchManager:
|
|
|
1347
1346
|
tag_pushed = True # Tag pushed = point of no return
|
|
1348
1347
|
# ✅ If we reach here: patch number globally reserved!
|
|
1349
1348
|
|
|
1349
|
+
# Step 11b: Sync release files to ho-prod (non-critical)
|
|
1350
|
+
self._sync_release_files_to_ho_prod(release_version, release_branch, critical=False)
|
|
1351
|
+
|
|
1350
1352
|
# === BRANCH CREATION (after reservation) ===
|
|
1351
1353
|
|
|
1352
1354
|
# Step 11: Create branch FROM current commit (after tag push)
|
|
@@ -1363,13 +1365,7 @@ class PatchManager:
|
|
|
1363
1365
|
click.echo(f"⚠️ Push branch manually: git push -u origin {branch_name}")
|
|
1364
1366
|
# Don't raise - tag pushed means success
|
|
1365
1367
|
|
|
1366
|
-
#
|
|
1367
|
-
try:
|
|
1368
|
-
self._checkout_branch(branch_name)
|
|
1369
|
-
except Exception as e:
|
|
1370
|
-
import click
|
|
1371
|
-
click.echo(f"⚠️ Checkout failed but patch created successfully")
|
|
1372
|
-
click.echo(f"Run: git checkout {branch_name}")
|
|
1368
|
+
# Note: No need to checkout - already on branch after _create_git_branch()
|
|
1373
1369
|
|
|
1374
1370
|
except Exception as e:
|
|
1375
1371
|
# Only rollback if tag NOT pushed yet AND modifications started
|
|
@@ -1385,10 +1381,6 @@ class PatchManager:
|
|
|
1385
1381
|
)
|
|
1386
1382
|
raise PatchManagerError(f"Patch creation failed: {e}")
|
|
1387
1383
|
|
|
1388
|
-
finally:
|
|
1389
|
-
# ALWAYS release lock (even on error)
|
|
1390
|
-
self._repo.hgit.release_branch_lock(lock_tag)
|
|
1391
|
-
|
|
1392
1384
|
# Return result
|
|
1393
1385
|
return {
|
|
1394
1386
|
'patch_id': normalized_id,
|
|
@@ -1398,6 +1390,80 @@ class PatchManager:
|
|
|
1398
1390
|
'version': release_version # NEW: include release version
|
|
1399
1391
|
}
|
|
1400
1392
|
|
|
1393
|
+
def _sync_release_files_to_ho_prod(self, version: str, release_branch: str, critical: bool = False) -> None:
|
|
1394
|
+
"""
|
|
1395
|
+
Sync release files for a specific version to ho-prod.
|
|
1396
|
+
|
|
1397
|
+
This ensures that ho-prod always has the latest state of release metadata
|
|
1398
|
+
files, making it the single source of truth for release information.
|
|
1399
|
+
|
|
1400
|
+
Args:
|
|
1401
|
+
version: Release version (e.g., "0.17.0")
|
|
1402
|
+
release_branch: Source release branch (e.g., "ho-release/0.17.0")
|
|
1403
|
+
critical: If True, raise exception on failure. If False, just warn.
|
|
1404
|
+
|
|
1405
|
+
Raises:
|
|
1406
|
+
PatchManagerError: If critical=True and sync fails
|
|
1407
|
+
|
|
1408
|
+
Examples:
|
|
1409
|
+
# After modifying candidates.txt on ho-release/0.17.0
|
|
1410
|
+
self._sync_release_files_to_ho_prod("0.17.0", "ho-release/0.17.0")
|
|
1411
|
+
"""
|
|
1412
|
+
try:
|
|
1413
|
+
# Save current branch
|
|
1414
|
+
current_branch = release_branch
|
|
1415
|
+
|
|
1416
|
+
# Checkout ho-prod
|
|
1417
|
+
self._repo.hgit.checkout("ho-prod")
|
|
1418
|
+
|
|
1419
|
+
# Copy ALL files for this specific version from release branch
|
|
1420
|
+
# This includes: -candidates.txt, -stage.txt, -rc*.txt, .txt, -hotfix*.txt
|
|
1421
|
+
# Using wildcard pattern to get all files matching the version
|
|
1422
|
+
self._repo.hgit.checkout_paths_from_branch(release_branch, [f"releases/{version}*.txt"])
|
|
1423
|
+
|
|
1424
|
+
# Commit on ho-prod
|
|
1425
|
+
self._repo.hgit.commit("-m", f"[HOP] sync release {version} files from {release_branch}")
|
|
1426
|
+
self._repo.hgit.push_branch("ho-prod")
|
|
1427
|
+
|
|
1428
|
+
# Return to release branch
|
|
1429
|
+
self._repo.hgit.checkout(current_branch)
|
|
1430
|
+
except Exception as e:
|
|
1431
|
+
# Try to return to release branch even on error
|
|
1432
|
+
try:
|
|
1433
|
+
self._repo.hgit.checkout(release_branch)
|
|
1434
|
+
except:
|
|
1435
|
+
pass
|
|
1436
|
+
|
|
1437
|
+
if critical:
|
|
1438
|
+
raise PatchManagerError(f"Failed to sync release files to ho-prod: {e}")
|
|
1439
|
+
else:
|
|
1440
|
+
# Non-critical - just warn
|
|
1441
|
+
import click
|
|
1442
|
+
click.echo(f"⚠️ Warning: Failed to sync release files to ho-prod: {e}")
|
|
1443
|
+
click.echo(f"⚠️ You may need to manually sync releases/ to ho-prod")
|
|
1444
|
+
|
|
1445
|
+
def _get_release_branch_for_patch(self, patch_id: str, *args, **kwargs) -> str:
|
|
1446
|
+
"""
|
|
1447
|
+
Helper to determine release branch for close_patch lock.
|
|
1448
|
+
|
|
1449
|
+
Args:
|
|
1450
|
+
patch_id: Patch identifier
|
|
1451
|
+
|
|
1452
|
+
Returns:
|
|
1453
|
+
Release branch name (e.g., "ho-release/0.17.0")
|
|
1454
|
+
|
|
1455
|
+
Raises:
|
|
1456
|
+
PatchManagerError: If patch not found in candidates
|
|
1457
|
+
"""
|
|
1458
|
+
version = self._find_version_for_candidate(patch_id)
|
|
1459
|
+
if not version:
|
|
1460
|
+
raise PatchManagerError(
|
|
1461
|
+
f"Patch {patch_id} not found in any candidates file.\n"
|
|
1462
|
+
f"Available candidates:\n{self._list_all_candidates()}"
|
|
1463
|
+
)
|
|
1464
|
+
return f"ho-release/{version}"
|
|
1465
|
+
|
|
1466
|
+
@with_dynamic_branch_lock(lambda self, patch_id: "ho-prod")
|
|
1401
1467
|
def close_patch(self, patch_id: str) -> dict:
|
|
1402
1468
|
"""
|
|
1403
1469
|
Close a patch by merging it into the release branch.
|
|
@@ -1463,7 +1529,7 @@ class PatchManager:
|
|
|
1463
1529
|
|
|
1464
1530
|
# 5. Merge patch branch into release branch
|
|
1465
1531
|
try:
|
|
1466
|
-
self._repo.hgit.merge(patch_branch, message=f
|
|
1532
|
+
self._repo.hgit.merge(patch_branch, message=f'''[HOP] Merge #{patch_id} into %"{version}"''')
|
|
1467
1533
|
except Exception as e:
|
|
1468
1534
|
raise PatchManagerError(
|
|
1469
1535
|
f"Failed to merge {patch_branch} into {release_branch}: {e}\n"
|
|
@@ -1473,13 +1539,16 @@ class PatchManager:
|
|
|
1473
1539
|
# 6. Move from candidates to stage
|
|
1474
1540
|
self._move_patch_to_stage(patch_id, version)
|
|
1475
1541
|
|
|
1476
|
-
# 7. Commit changes
|
|
1542
|
+
# 7. Commit changes on release branch
|
|
1477
1543
|
try:
|
|
1478
1544
|
self._repo.hgit.commit("-m", f"[HOP] move patch #{patch_id} from candidate to stage %{version}")
|
|
1479
1545
|
self._repo.hgit.push_branch(release_branch)
|
|
1480
1546
|
except Exception as e:
|
|
1481
1547
|
raise PatchManagerError(f"Failed to commit/push changes: {e}")
|
|
1482
1548
|
|
|
1549
|
+
# 7b. Sync release files to ho-prod (metadata only, not code)
|
|
1550
|
+
self._sync_release_files_to_ho_prod(version, release_branch, critical=True)
|
|
1551
|
+
|
|
1483
1552
|
# 8. Delete patch branch (local and remote)
|
|
1484
1553
|
try:
|
|
1485
1554
|
self._repo.hgit.delete_local_branch(patch_branch)
|
|
@@ -1638,14 +1707,14 @@ class PatchManager:
|
|
|
1638
1707
|
to allow patches to see each other's changes and support dependencies.
|
|
1639
1708
|
|
|
1640
1709
|
Returns:
|
|
1641
|
-
|
|
1710
|
+
branch name (e.g., "ho-release/0.17.0")
|
|
1642
1711
|
|
|
1643
1712
|
Raises:
|
|
1644
1713
|
PatchManagerError: If not on ho-release/* branch
|
|
1645
1714
|
|
|
1646
1715
|
Examples:
|
|
1647
|
-
|
|
1648
|
-
# On ho-release/
|
|
1716
|
+
branch_name = self._validate_on_ho_release()
|
|
1717
|
+
# On ho-release/X.Y.Z → returns "ho-release/X.Y.Z"
|
|
1649
1718
|
# On main → raises PatchManagerError
|
|
1650
1719
|
"""
|
|
1651
1720
|
current_branch = self._repo.hgit.branch
|
|
@@ -1656,9 +1725,7 @@ class PatchManager:
|
|
|
1656
1725
|
f"Hint: Run 'half_orm dev release new <level>' first to create a release"
|
|
1657
1726
|
)
|
|
1658
1727
|
|
|
1659
|
-
|
|
1660
|
-
version = current_branch.replace("ho-release/", "")
|
|
1661
|
-
return version
|
|
1728
|
+
return current_branch
|
|
1662
1729
|
|
|
1663
1730
|
def _validate_on_ho_prod(self) -> None:
|
|
1664
1731
|
"""
|
|
@@ -16,7 +16,7 @@ from typing import Optional, Tuple, List, Dict
|
|
|
16
16
|
from dataclasses import dataclass
|
|
17
17
|
|
|
18
18
|
from git.exc import GitCommandError
|
|
19
|
-
from half_orm_dev.decorators import
|
|
19
|
+
from half_orm_dev.decorators import with_dynamic_branch_lock
|
|
20
20
|
|
|
21
21
|
class ReleaseManagerError(Exception):
|
|
22
22
|
"""Base exception for ReleaseManager operations."""
|
|
@@ -2285,7 +2285,7 @@ class ReleaseManager:
|
|
|
2285
2285
|
version_tags.sort(key=version_key, reverse=True)
|
|
2286
2286
|
return version_tags[0]
|
|
2287
2287
|
|
|
2288
|
-
@
|
|
2288
|
+
@with_dynamic_branch_lock(lambda self, level: "ho-prod")
|
|
2289
2289
|
def new_release(self, level: str) -> dict:
|
|
2290
2290
|
"""
|
|
2291
2291
|
Create a new release with integration branch.
|
|
@@ -2380,7 +2380,7 @@ class ReleaseManager:
|
|
|
2380
2380
|
'stage_file': str(stage_file)
|
|
2381
2381
|
}
|
|
2382
2382
|
|
|
2383
|
-
@
|
|
2383
|
+
@with_dynamic_branch_lock(lambda self, patch_id, version: "ho-prod")
|
|
2384
2384
|
def add_patch_to_release(self, patch_id: str, version: str) -> dict:
|
|
2385
2385
|
"""
|
|
2386
2386
|
Add a patch to a release by merging into the release branch.
|
|
@@ -2526,8 +2526,8 @@ class ReleaseManager:
|
|
|
2526
2526
|
stage_files.sort(key=version_key)
|
|
2527
2527
|
return stage_files[0].stem.replace('-stage', '')
|
|
2528
2528
|
|
|
2529
|
-
@
|
|
2530
|
-
def promote_to_rc(self
|
|
2529
|
+
@with_dynamic_branch_lock(lambda self: "ho-prod")
|
|
2530
|
+
def promote_to_rc(self) -> dict:
|
|
2531
2531
|
"""
|
|
2532
2532
|
Promote a stage release to RC by tagging the release branch.
|
|
2533
2533
|
|
|
@@ -2548,7 +2548,7 @@ class ReleaseManager:
|
|
|
2548
2548
|
ReleaseManagerError: If promotion fails
|
|
2549
2549
|
|
|
2550
2550
|
Examples:
|
|
2551
|
-
rel_mgr.promote_to_rc(
|
|
2551
|
+
rel_mgr.promote_to_rc()
|
|
2552
2552
|
# → Creates tag "0.1.0-rcN" on ho-release/0.1.0
|
|
2553
2553
|
# → Renames 0.1.0-stage.txt to 0.1.0-rcN.txt
|
|
2554
2554
|
|
|
@@ -2556,8 +2556,7 @@ class ReleaseManager:
|
|
|
2556
2556
|
# → Promotes the smallest stage release
|
|
2557
2557
|
"""
|
|
2558
2558
|
# Auto-detect version if not provided
|
|
2559
|
-
|
|
2560
|
-
version = self._detect_version_to_promote('rc')
|
|
2559
|
+
version = self._detect_version_to_promote('rc')
|
|
2561
2560
|
|
|
2562
2561
|
stage_file = self._releases_dir / f"{version}-stage.txt"
|
|
2563
2562
|
if not stage_file.exists():
|
|
@@ -2597,6 +2596,13 @@ class ReleaseManager:
|
|
|
2597
2596
|
self._repo.hgit.commit("-m", f"[HOP] Promote release %{version} to RC {rc_number}")
|
|
2598
2597
|
self._repo.hgit.push_branch(release_branch)
|
|
2599
2598
|
|
|
2599
|
+
# Sync RC and stage files to ho-prod (single source of truth)
|
|
2600
|
+
self._repo.patch_manager._sync_release_files_to_ho_prod(
|
|
2601
|
+
version,
|
|
2602
|
+
release_branch,
|
|
2603
|
+
critical=False
|
|
2604
|
+
)
|
|
2605
|
+
|
|
2600
2606
|
return {
|
|
2601
2607
|
'version': version,
|
|
2602
2608
|
'tag': rc_tag,
|
|
@@ -2606,8 +2612,8 @@ class ReleaseManager:
|
|
|
2606
2612
|
except Exception as e:
|
|
2607
2613
|
raise ReleaseManagerError(f"Failed to promote to RC: {e}")
|
|
2608
2614
|
|
|
2609
|
-
@
|
|
2610
|
-
def promote_to_prod(self
|
|
2615
|
+
@with_dynamic_branch_lock(lambda self: "ho-prod")
|
|
2616
|
+
def promote_to_prod(self) -> dict:
|
|
2611
2617
|
"""
|
|
2612
2618
|
Promote stage release to production.
|
|
2613
2619
|
|
|
@@ -2635,7 +2641,7 @@ class ReleaseManager:
|
|
|
2635
2641
|
ReleaseManagerError: If promotion fails
|
|
2636
2642
|
|
|
2637
2643
|
Examples:
|
|
2638
|
-
rel_mgr.promote_to_prod(
|
|
2644
|
+
rel_mgr.promote_to_prod()
|
|
2639
2645
|
# → Merges ho-release/0.1.0 into ho-prod
|
|
2640
2646
|
# → Creates tag "0.1.0"
|
|
2641
2647
|
# → Deletes candidates.txt, renames stage.txt to 0.1.0.txt
|
|
@@ -2644,9 +2650,8 @@ class ReleaseManager:
|
|
|
2644
2650
|
rel_mgr.promote_to_prod() # Auto-detect version
|
|
2645
2651
|
# → Promotes the smallest RC release
|
|
2646
2652
|
"""
|
|
2647
|
-
# Auto-detect version
|
|
2648
|
-
|
|
2649
|
-
version = self._detect_version_to_promote('prod')
|
|
2653
|
+
# Auto-detect version
|
|
2654
|
+
version = self._detect_version_to_promote('prod')
|
|
2650
2655
|
|
|
2651
2656
|
stage_file = self._releases_dir / f"{version}-stage.txt"
|
|
2652
2657
|
if not stage_file.exists():
|
|
@@ -2702,10 +2707,31 @@ class ReleaseManager:
|
|
|
2702
2707
|
"ho-prod has been restored to its previous state."
|
|
2703
2708
|
)
|
|
2704
2709
|
|
|
2705
|
-
# 3. Apply patches and generate schema
|
|
2706
|
-
|
|
2710
|
+
# 3. Apply only NEW patches from stage (if any) and generate schema
|
|
2711
|
+
# Note: RC patches have already been applied during promote_to_rc
|
|
2712
|
+
stage_patches = self.read_release_patches(f"{version}-stage.txt")
|
|
2713
|
+
|
|
2714
|
+
if stage_patches:
|
|
2715
|
+
# There are new patches after RC - apply them
|
|
2716
|
+
# First, restore from latest RC schema
|
|
2717
|
+
latest_rc = self._get_latest_rc_number(version)
|
|
2718
|
+
rc_schema_version = f"{version}-rc{latest_rc}" if latest_rc > 0 else "0.0.0"
|
|
2719
|
+
|
|
2720
|
+
# Restore from RC schema if it exists, otherwise from baseline
|
|
2721
|
+
try:
|
|
2722
|
+
self._repo.restore_database_from_schema(rc_schema_version)
|
|
2723
|
+
except:
|
|
2724
|
+
self._repo.restore_database_from_schema()
|
|
2725
|
+
|
|
2726
|
+
# Apply new stage patches
|
|
2727
|
+
for patch_id in stage_patches:
|
|
2728
|
+
self._repo.patch_manager.apply_patch_files(patch_id, self._repo.model)
|
|
2729
|
+
else:
|
|
2730
|
+
# No new patches - just restore from latest RC
|
|
2731
|
+
# The database should already be in the correct state from RC
|
|
2732
|
+
pass
|
|
2707
2733
|
|
|
2708
|
-
# Generate schema dump for this version
|
|
2734
|
+
# Generate schema dump for this production version
|
|
2709
2735
|
model_dir = Path(self._repo.base_dir) / "model"
|
|
2710
2736
|
self._repo.database._generate_schema_sql(version, model_dir)
|
|
2711
2737
|
|
|
@@ -2761,6 +2787,39 @@ class ReleaseManager:
|
|
|
2761
2787
|
except Exception as e:
|
|
2762
2788
|
raise ReleaseManagerError(f"Failed to promote to production: {e}")
|
|
2763
2789
|
|
|
2790
|
+
def _get_latest_rc_number(self, version: str) -> int:
|
|
2791
|
+
"""
|
|
2792
|
+
Get the latest (highest) RC number for a version.
|
|
2793
|
+
|
|
2794
|
+
Args:
|
|
2795
|
+
version: Version string (e.g., "1.3.5")
|
|
2796
|
+
|
|
2797
|
+
Returns:
|
|
2798
|
+
Latest RC number (0 if no RCs exist, 1 for rc1, 2 for rc2, etc.)
|
|
2799
|
+
|
|
2800
|
+
Examples:
|
|
2801
|
+
# No RCs
|
|
2802
|
+
latest = mgr._get_latest_rc_number("1.3.5")
|
|
2803
|
+
# → 0
|
|
2804
|
+
|
|
2805
|
+
# rc1, rc2 exist
|
|
2806
|
+
latest = mgr._get_latest_rc_number("1.3.5")
|
|
2807
|
+
# → 2
|
|
2808
|
+
"""
|
|
2809
|
+
rc_files = self.get_rc_files(version)
|
|
2810
|
+
if not rc_files:
|
|
2811
|
+
return 0
|
|
2812
|
+
|
|
2813
|
+
# Extract RC numbers from filenames
|
|
2814
|
+
rc_numbers = []
|
|
2815
|
+
for rc_file in rc_files:
|
|
2816
|
+
# Format: "1.3.5-rc2.txt" → extract "2"
|
|
2817
|
+
match = re.search(r'-rc(\d+)\.txt$', rc_file.name)
|
|
2818
|
+
if match:
|
|
2819
|
+
rc_numbers.append(int(match.group(1)))
|
|
2820
|
+
|
|
2821
|
+
return max(rc_numbers) if rc_numbers else 0
|
|
2822
|
+
|
|
2764
2823
|
def _determine_rc_number(self, version: str) -> int:
|
|
2765
2824
|
"""
|
|
2766
2825
|
Determine next RC number for version.
|
|
@@ -2812,8 +2871,8 @@ class ReleaseManager:
|
|
|
2812
2871
|
# Fallback (shouldn't happen with valid RC files)
|
|
2813
2872
|
return len(rc_files) + 1
|
|
2814
2873
|
|
|
2815
|
-
@
|
|
2816
|
-
def reopen_for_hotfix(self
|
|
2874
|
+
@with_dynamic_branch_lock(lambda self: "ho-prod")
|
|
2875
|
+
def reopen_for_hotfix(self) -> dict:
|
|
2817
2876
|
"""
|
|
2818
2877
|
Reopen a production version for hotfix development.
|
|
2819
2878
|
|
|
@@ -2855,10 +2914,8 @@ class ReleaseManager:
|
|
|
2855
2914
|
8. Switch to branch
|
|
2856
2915
|
"""
|
|
2857
2916
|
try:
|
|
2858
|
-
#
|
|
2859
|
-
|
|
2860
|
-
# Get current production version from model/schema.sql
|
|
2861
|
-
version = self._get_production_version()
|
|
2917
|
+
# Get current production version from model/schema.sql
|
|
2918
|
+
version = self._get_production_version()
|
|
2862
2919
|
|
|
2863
2920
|
# Validate version format
|
|
2864
2921
|
if not re.match(r'^\d+\.\d+\.\d+$', version):
|
|
@@ -2936,6 +2993,7 @@ class ReleaseManager:
|
|
|
2936
2993
|
except Exception as e:
|
|
2937
2994
|
raise ReleaseManagerError(f"Failed to reopen version for hotfix: {e}")
|
|
2938
2995
|
|
|
2996
|
+
@with_dynamic_branch_lock(lambda self: "ho-prod")
|
|
2939
2997
|
def promote_to_hotfix(self) -> dict:
|
|
2940
2998
|
"""
|
|
2941
2999
|
Promote hotfix release to production with hotfix tag.
|
|
@@ -2943,14 +3001,16 @@ class ReleaseManager:
|
|
|
2943
3001
|
Similar to promote_to_prod() but:
|
|
2944
3002
|
- Works from ho-release/X.Y.Z branch (not ho-prod)
|
|
2945
3003
|
- Creates hotfix tag vX.Y.Z-hotfixN (not vX.Y.Z)
|
|
3004
|
+
- Renames stage.txt to X.Y.Z-hotfixN.txt
|
|
2946
3005
|
- Merges ho-release/X.Y.Z into ho-prod
|
|
2947
|
-
-
|
|
3006
|
+
- Deletes ho-release/X.Y.Z branch after promotion
|
|
2948
3007
|
|
|
2949
3008
|
Returns:
|
|
2950
3009
|
dict with:
|
|
2951
3010
|
- version: Base version (e.g., "1.3.5")
|
|
2952
3011
|
- hotfix_tag: Tag created (e.g., "v1.3.5-hotfix1")
|
|
2953
3012
|
- branch: Branch used (e.g., "ho-release/1.3.5")
|
|
3013
|
+
- deleted_branches: List of deleted branches
|
|
2954
3014
|
|
|
2955
3015
|
Raises:
|
|
2956
3016
|
ReleaseManagerError: If validation fails or promotion errors occur
|
|
@@ -2960,9 +3020,12 @@ class ReleaseManager:
|
|
|
2960
3020
|
2. Verify candidates.txt is empty
|
|
2961
3021
|
3. Determine next hotfix number
|
|
2962
3022
|
4. Merge ho-release/X.Y.Z into ho-prod
|
|
2963
|
-
5.
|
|
2964
|
-
6.
|
|
2965
|
-
7.
|
|
3023
|
+
5. Rename stage.txt to X.Y.Z-hotfixN.txt and delete candidates.txt
|
|
3024
|
+
6. Apply release patches and generate SQL dumps on ho-prod
|
|
3025
|
+
7. Commit release file changes
|
|
3026
|
+
8. Create hotfix tag vX.Y.Z-hotfixN on ho-prod
|
|
3027
|
+
9. Push ho-prod
|
|
3028
|
+
10. Delete ho-release/X.Y.Z branch
|
|
2966
3029
|
"""
|
|
2967
3030
|
try:
|
|
2968
3031
|
# 1. Verify on ho-release/* branch
|
|
@@ -3009,19 +3072,37 @@ class ReleaseManager:
|
|
|
3009
3072
|
merge_msg = f"[release] Merge hotfix %{version}-hotfix{hotfix_num}"
|
|
3010
3073
|
self._repo.hgit.merge(current_branch, message=merge_msg)
|
|
3011
3074
|
|
|
3012
|
-
# 5.
|
|
3075
|
+
# 5. Rename stage file to hotfix file and delete candidates
|
|
3076
|
+
stage_file = self._releases_dir / f"{version}-stage.txt"
|
|
3077
|
+
hotfix_file = self._releases_dir / f"{version}-hotfix{hotfix_num}.txt"
|
|
3078
|
+
candidates_file = self._releases_dir / f"{version}-candidates.txt"
|
|
3079
|
+
|
|
3080
|
+
if stage_file.exists():
|
|
3081
|
+
stage_file.rename(hotfix_file)
|
|
3082
|
+
self._repo.hgit.add(str(stage_file)) # Old path (deleted)
|
|
3083
|
+
self._repo.hgit.add(str(hotfix_file)) # New path (created)
|
|
3084
|
+
|
|
3085
|
+
# Delete candidates file if it exists
|
|
3086
|
+
if candidates_file.exists():
|
|
3087
|
+
candidates_file.unlink()
|
|
3088
|
+
self._repo.hgit.add(str(candidates_file)) # Mark as deleted
|
|
3089
|
+
|
|
3090
|
+
# 6. Apply release patches and generate SQL dumps
|
|
3013
3091
|
self._apply_release_patches(version, True)
|
|
3014
3092
|
|
|
3015
|
-
#
|
|
3093
|
+
# 7. Commit release file changes
|
|
3094
|
+
self._repo.hgit.commit("-m", f"[HOP] Finalize hotfix %{version}-hotfix{hotfix_num} release files")
|
|
3095
|
+
|
|
3096
|
+
# 8. Create hotfix tag on ho-prod
|
|
3016
3097
|
self._repo.hgit.create_tag(hotfix_tag, f"Hotfix release %{version}-hotfix{hotfix_num}")
|
|
3017
3098
|
self._repo.hgit.push_tag(hotfix_tag)
|
|
3018
3099
|
|
|
3019
|
-
#
|
|
3100
|
+
# 9. Push ho-prod
|
|
3020
3101
|
self._repo.hgit.push_branch("ho-prod")
|
|
3021
3102
|
|
|
3022
3103
|
deleted_branches = []
|
|
3023
3104
|
|
|
3024
|
-
#
|
|
3105
|
+
# 10. Delete release branch (force=True because Git may not recognize the merge)
|
|
3025
3106
|
try:
|
|
3026
3107
|
self._repo.hgit.delete_branch(current_branch, force=True)
|
|
3027
3108
|
self._repo.hgit.delete_remote_branch(current_branch)
|
|
@@ -773,34 +773,59 @@ class Repo:
|
|
|
773
773
|
|
|
774
774
|
# 2. Get active branches status and release files
|
|
775
775
|
try:
|
|
776
|
-
# Find release files (candidates and stage)
|
|
776
|
+
# Find release files (candidates and stage) from ho-prod
|
|
777
777
|
releases_info = {}
|
|
778
778
|
if hasattr(self, 'release_manager'):
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
#
|
|
798
|
-
|
|
799
|
-
by_version
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
779
|
+
# Save current branch
|
|
780
|
+
try:
|
|
781
|
+
current_branch = str(self.hgit._HGit__git_repo.active_branch)
|
|
782
|
+
except:
|
|
783
|
+
current_branch = None
|
|
784
|
+
|
|
785
|
+
# Switch to ho-prod to read release files (source of truth)
|
|
786
|
+
try:
|
|
787
|
+
self.hgit.checkout('ho-prod')
|
|
788
|
+
|
|
789
|
+
# Pull latest changes from origin to ensure we have up-to-date metadata
|
|
790
|
+
try:
|
|
791
|
+
self.hgit.pull()
|
|
792
|
+
except Exception:
|
|
793
|
+
pass # Best effort - may fail if no remote or no changes
|
|
794
|
+
|
|
795
|
+
releases_dir = Path(self.__base_dir) / 'releases'
|
|
796
|
+
if releases_dir.exists():
|
|
797
|
+
# Group files by version
|
|
798
|
+
from collections import defaultdict
|
|
799
|
+
by_version = defaultdict(dict)
|
|
800
|
+
|
|
801
|
+
for candidates_file in releases_dir.glob('*-candidates.txt'):
|
|
802
|
+
version = candidates_file.stem.replace('-candidates', '')
|
|
803
|
+
by_version[version]['candidates_file'] = str(candidates_file)
|
|
804
|
+
# Read candidates (skip comments)
|
|
805
|
+
candidates_content = candidates_file.read_text(encoding='utf-8').strip()
|
|
806
|
+
by_version[version]['candidates'] = [
|
|
807
|
+
c.strip() for c in candidates_content.split('\n')
|
|
808
|
+
if c.strip() and not c.startswith('#')
|
|
809
|
+
]
|
|
810
|
+
|
|
811
|
+
for stage_file in releases_dir.glob('*-stage.txt'):
|
|
812
|
+
version = stage_file.stem.replace('-stage', '')
|
|
813
|
+
by_version[version]['stage_file'] = str(stage_file)
|
|
814
|
+
# Read staged patches (skip comments)
|
|
815
|
+
stage_content = stage_file.read_text(encoding='utf-8').strip()
|
|
816
|
+
by_version[version]['staged'] = [
|
|
817
|
+
s.strip() for s in stage_content.split('\n')
|
|
818
|
+
if s.strip() and not s.startswith('#')
|
|
819
|
+
]
|
|
820
|
+
|
|
821
|
+
releases_info = dict(by_version)
|
|
822
|
+
finally:
|
|
823
|
+
# Return to original branch
|
|
824
|
+
if current_branch:
|
|
825
|
+
try:
|
|
826
|
+
self.hgit.checkout(current_branch)
|
|
827
|
+
except:
|
|
828
|
+
pass # Best effort
|
|
804
829
|
|
|
805
830
|
result['active_branches'] = self.hgit.get_active_branches_status(
|
|
806
831
|
stage_files=[info.get('stage_file') for info in releases_info.values()
|
|
@@ -1798,16 +1823,3 @@ See docs/half_orm_dev.md for complete documentation.
|
|
|
1798
1823
|
raise RepoError(
|
|
1799
1824
|
f"Failed to restore database from schema: {e}"
|
|
1800
1825
|
) from e
|
|
1801
|
-
|
|
1802
|
-
def temp_lock(func):
|
|
1803
|
-
def wrapper(*args, **kwargs):
|
|
1804
|
-
repo = Repo()
|
|
1805
|
-
lock_tag = None
|
|
1806
|
-
try:
|
|
1807
|
-
lock_tag = repo.hgit.acquire_branch_lock("ho-prod", timeout_minutes=30)
|
|
1808
|
-
result = func(*args, **kwargs)
|
|
1809
|
-
result['lock_tag'] = lock_tag
|
|
1810
|
-
finally:
|
|
1811
|
-
repo.hgit.release_branch_lock(lock_tag)
|
|
1812
|
-
return result
|
|
1813
|
-
return wrapper
|
|
@@ -47,16 +47,3 @@ def resolve_database_config_name(base_dir):
|
|
|
47
47
|
|
|
48
48
|
# Priority 3: directory name
|
|
49
49
|
return base_path.name
|
|
50
|
-
|
|
51
|
-
def lock_tag(repo: 'Repo'):
|
|
52
|
-
def temp_lock(func):
|
|
53
|
-
def wrapper(*args, **kwargs):
|
|
54
|
-
lock_tag = None
|
|
55
|
-
try:
|
|
56
|
-
lock_tag = repo.hgit.acquire_branch_lock("ho-prod", timeout_minutes=30)
|
|
57
|
-
result = func(*args, **kwargs)
|
|
58
|
-
finally:
|
|
59
|
-
if lock_tag:
|
|
60
|
-
repo.hgit.release_branch_lock(lock_tag)
|
|
61
|
-
return result
|
|
62
|
-
return wrapper
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.17.0-a10
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.17.0-a7
|
|
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
|
|
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-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/git-hooks/pre-commit
RENAMED
|
File without changes
|
{half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/git-hooks/prepare-commit-msg
RENAMED
|
File without changes
|
{half_orm_dev-0.17.0a7 → half_orm_dev-0.17.0a10}/half_orm_dev/templates/init_module_template
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
|