half-orm-dev 0.17.5a2__tar.gz → 0.17.5a4__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.5a2/half_orm_dev.egg-info → half_orm_dev-0.17.5a4}/PKG-INFO +1 -1
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/bootstrap_manager.py +41 -13
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/check.py +8 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/patch.py +85 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/release.py +64 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/decorators.py +15 -4
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patch_manager.py +245 -20
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/release_manager.py +314 -330
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/repo.py +50 -8
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/git-hooks/pre-commit +3 -2
- half_orm_dev-0.17.5a4/half_orm_dev/version.txt +1 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4/half_orm_dev.egg-info}/PKG-INFO +1 -1
- half_orm_dev-0.17.5a2/half_orm_dev/version.txt +0 -1
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/AUTHORS +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/LICENSE +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/README.md +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/__init__.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/__init__.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/__init__.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/apply.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/bootstrap.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/clone.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/init.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/migrate.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/restore.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/sync.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/todo.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/undo.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/update.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/upgrade.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/main.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli_extension.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/database.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/file_executor.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/hgit.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/migration_manager.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/modules.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patch_validator.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patches/log +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/release_file.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/scripts/repair-metadata.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/.gitignore +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/MANIFEST.in +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/README +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/conftest_template +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/init_module_template +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/module_template_1 +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/module_template_2 +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/module_template_3 +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/pyproject.toml +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/relation_test +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/sql_adapter +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/warning +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/utils.py +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev.egg-info/SOURCES.txt +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev.egg-info/dependency_links.txt +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev.egg-info/requires.txt +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev.egg-info/top_level.txt +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/pyproject.toml +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/setup.cfg +0 -0
- {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/setup.py +0 -0
|
@@ -78,7 +78,12 @@ class BootstrapManager:
|
|
|
78
78
|
# Schema might not exist yet, ignore
|
|
79
79
|
pass
|
|
80
80
|
|
|
81
|
-
def get_bootstrap_files(
|
|
81
|
+
def get_bootstrap_files(
|
|
82
|
+
self,
|
|
83
|
+
up_to_version: Optional[str] = None,
|
|
84
|
+
exclude_version: Optional[str] = None,
|
|
85
|
+
for_version: Optional[str] = None
|
|
86
|
+
) -> List[Path]:
|
|
82
87
|
"""
|
|
83
88
|
List bootstrap files sorted by numeric prefix.
|
|
84
89
|
|
|
@@ -87,8 +92,11 @@ class BootstrapManager:
|
|
|
87
92
|
|
|
88
93
|
Args:
|
|
89
94
|
up_to_version: If provided, only return files with version <= this version.
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
exclude_version: If provided, exclude files for exactly this version.
|
|
96
|
+
Used during promote to skip the version being promoted
|
|
97
|
+
(its bootstraps run after patches are applied).
|
|
98
|
+
for_version: If provided, only return files for exactly this version.
|
|
99
|
+
Used after patches to run only the promoted version's bootstraps.
|
|
92
100
|
|
|
93
101
|
Returns:
|
|
94
102
|
List of Path objects for bootstrap files in execution order
|
|
@@ -103,11 +111,20 @@ class BootstrapManager:
|
|
|
103
111
|
if not re.match(r'^\d+-', file_path.name):
|
|
104
112
|
continue
|
|
105
113
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
file_version = self._extract_version_from_filename(file_path.name)
|
|
115
|
+
|
|
116
|
+
# If for_version is specified, only include files for exactly this version
|
|
117
|
+
if for_version:
|
|
118
|
+
if file_version != for_version:
|
|
119
|
+
continue
|
|
120
|
+
else:
|
|
121
|
+
# If exclude_version is specified, skip files for that version
|
|
122
|
+
if exclude_version and file_version == exclude_version:
|
|
110
123
|
continue
|
|
124
|
+
# If up_to_version is specified, filter out files from newer versions
|
|
125
|
+
if up_to_version:
|
|
126
|
+
if file_version != 'unknown' and not self._version_le(file_version, up_to_version):
|
|
127
|
+
continue
|
|
111
128
|
|
|
112
129
|
files.append(file_path)
|
|
113
130
|
|
|
@@ -140,17 +157,24 @@ class BootstrapManager:
|
|
|
140
157
|
# Table might not exist yet (pre-migration)
|
|
141
158
|
return set()
|
|
142
159
|
|
|
143
|
-
def get_pending_files(
|
|
160
|
+
def get_pending_files(
|
|
161
|
+
self,
|
|
162
|
+
up_to_version: Optional[str] = None,
|
|
163
|
+
exclude_version: Optional[str] = None,
|
|
164
|
+
for_version: Optional[str] = None
|
|
165
|
+
) -> List[Path]:
|
|
144
166
|
"""
|
|
145
167
|
Get bootstrap files not yet executed.
|
|
146
168
|
|
|
147
169
|
Args:
|
|
148
170
|
up_to_version: If provided, only return files with version <= this version.
|
|
171
|
+
exclude_version: If provided, exclude files for exactly this version.
|
|
172
|
+
for_version: If provided, only return files for exactly this version.
|
|
149
173
|
|
|
150
174
|
Returns:
|
|
151
175
|
List of Path objects for files pending execution
|
|
152
176
|
"""
|
|
153
|
-
all_files = self.get_bootstrap_files(up_to_version)
|
|
177
|
+
all_files = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
|
|
154
178
|
executed = self.get_executed_files()
|
|
155
179
|
|
|
156
180
|
return [f for f in all_files if f.name not in executed]
|
|
@@ -201,7 +225,9 @@ class BootstrapManager:
|
|
|
201
225
|
dry_run: bool = False,
|
|
202
226
|
force: bool = False,
|
|
203
227
|
exclude_patch_id: Optional[str] = None,
|
|
204
|
-
up_to_version: Optional[str] = None
|
|
228
|
+
up_to_version: Optional[str] = None,
|
|
229
|
+
exclude_version: Optional[str] = None,
|
|
230
|
+
for_version: Optional[str] = None
|
|
205
231
|
) -> dict:
|
|
206
232
|
"""
|
|
207
233
|
Execute pending bootstrap files.
|
|
@@ -215,6 +241,8 @@ class BootstrapManager:
|
|
|
215
241
|
up_to_version: If provided, only execute files with version <= this version.
|
|
216
242
|
Used during restore_database_from_schema to avoid executing
|
|
217
243
|
bootstraps for future releases.
|
|
244
|
+
exclude_version: If provided, exclude files for exactly this version.
|
|
245
|
+
for_version: If provided, only execute files for exactly this version.
|
|
218
246
|
|
|
219
247
|
Returns:
|
|
220
248
|
Dict with execution results:
|
|
@@ -231,11 +259,11 @@ class BootstrapManager:
|
|
|
231
259
|
}
|
|
232
260
|
|
|
233
261
|
if force:
|
|
234
|
-
files_to_execute = self.get_bootstrap_files(up_to_version)
|
|
262
|
+
files_to_execute = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
|
|
235
263
|
else:
|
|
236
|
-
files_to_execute = self.get_pending_files(up_to_version)
|
|
264
|
+
files_to_execute = self.get_pending_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
|
|
237
265
|
# Calculate skipped
|
|
238
|
-
all_files = self.get_bootstrap_files(up_to_version)
|
|
266
|
+
all_files = self.get_bootstrap_files(up_to_version, exclude_version=exclude_version, for_version=for_version)
|
|
239
267
|
executed = self.get_executed_files()
|
|
240
268
|
result['skipped'] = [f.name for f in all_files if f.name in executed]
|
|
241
269
|
|
|
@@ -153,6 +153,14 @@ def _display_check_results(repo, result: dict, dry_run: bool, verbose: bool):
|
|
|
153
153
|
elif verbose:
|
|
154
154
|
click.echo(f"\n📦 {utils.Color.bold('Active releases:')} None")
|
|
155
155
|
|
|
156
|
+
# Show orphaned patches
|
|
157
|
+
orphaned_patches = result.get('orphaned_patches', [])
|
|
158
|
+
if orphaned_patches:
|
|
159
|
+
click.echo(f"\n🔧 {utils.Color.bold('Orphaned patches')} ({len(orphaned_patches)}):")
|
|
160
|
+
for patch_id in sorted(orphaned_patches):
|
|
161
|
+
click.echo(f" • {patch_id}")
|
|
162
|
+
click.echo(f" {utils.Color.blue('(Use \"half_orm dev release attach-patch <id>\" to reattach)')}")
|
|
163
|
+
|
|
156
164
|
# Show standalone patch branches (not in candidates/stage)
|
|
157
165
|
standalone_patches = [b for b in patch_branches
|
|
158
166
|
if not _is_patch_in_releases(b['name'], releases_info)]
|
|
@@ -5,6 +5,7 @@ Groups all patch-related commands under 'half_orm dev patch':
|
|
|
5
5
|
- patch create: Create new patch branch and directory
|
|
6
6
|
- patch apply: Apply current patch files to database
|
|
7
7
|
- patch merge: Add patch to stage release with validation
|
|
8
|
+
- patch detach: Detach a candidate patch from its release
|
|
8
9
|
|
|
9
10
|
Replaces legacy commands:
|
|
10
11
|
- create-patch → patch create
|
|
@@ -365,3 +366,87 @@ def patch_merge(force: bool) -> None:
|
|
|
365
366
|
|
|
366
367
|
except PatchManagerError as e:
|
|
367
368
|
raise click.ClickException(str(e))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@patch.command('detach')
|
|
372
|
+
@click.argument('patch_id', type=str, required=False)
|
|
373
|
+
@click.option('--force', '-f', is_flag=True, help='Skip confirmation prompt')
|
|
374
|
+
def patch_detach(patch_id: Optional[str], force: bool) -> None:
|
|
375
|
+
"""
|
|
376
|
+
Detach a candidate patch from its release.
|
|
377
|
+
|
|
378
|
+
Moves the patch to Patches/orphaned/ directory.
|
|
379
|
+
The git branch is preserved for future reattachment.
|
|
380
|
+
|
|
381
|
+
If PATCH_ID is not provided, uses the current branch's patch.
|
|
382
|
+
|
|
383
|
+
Examples:
|
|
384
|
+
Detach current patch (from ho-patch/* branch):
|
|
385
|
+
$ half_orm dev patch detach
|
|
386
|
+
|
|
387
|
+
Detach specific patch:
|
|
388
|
+
$ half_orm dev patch detach 123-feature
|
|
389
|
+
|
|
390
|
+
Detach without confirmation:
|
|
391
|
+
$ half_orm dev patch detach --force
|
|
392
|
+
"""
|
|
393
|
+
try:
|
|
394
|
+
repo = Repo()
|
|
395
|
+
|
|
396
|
+
# Auto-detect patch_id from current branch if not provided
|
|
397
|
+
if not patch_id:
|
|
398
|
+
branch = repo.hgit.branch
|
|
399
|
+
if not branch.startswith('ho-patch/'):
|
|
400
|
+
raise click.UsageError(
|
|
401
|
+
"Not on a patch branch. Provide PATCH_ID or checkout ho-patch/* branch."
|
|
402
|
+
)
|
|
403
|
+
patch_id = branch.replace('ho-patch/', '')
|
|
404
|
+
|
|
405
|
+
# Get patch info for confirmation
|
|
406
|
+
status_map = repo.patch_manager.get_patch_status_map()
|
|
407
|
+
if patch_id not in status_map:
|
|
408
|
+
click.echo(utils.Color.red(f"Patch '{patch_id}' not found"))
|
|
409
|
+
raise click.Abort()
|
|
410
|
+
|
|
411
|
+
patch_info = status_map[patch_id]
|
|
412
|
+
version = patch_info.get('version', 'unknown')
|
|
413
|
+
status = patch_info.get('status', 'unknown')
|
|
414
|
+
|
|
415
|
+
# Check if patch can be detached
|
|
416
|
+
if status == 'staged':
|
|
417
|
+
click.echo(utils.Color.red(
|
|
418
|
+
f"Cannot detach staged patch '{patch_id}'. "
|
|
419
|
+
"Only candidate patches can be detached."
|
|
420
|
+
))
|
|
421
|
+
raise click.Abort()
|
|
422
|
+
|
|
423
|
+
if status == 'orphaned':
|
|
424
|
+
click.echo(utils.Color.red(f"Patch '{patch_id}' is already orphaned."))
|
|
425
|
+
raise click.Abort()
|
|
426
|
+
|
|
427
|
+
# Confirmation
|
|
428
|
+
if not force:
|
|
429
|
+
click.echo(f"Detaching patch '{utils.Color.bold(patch_id)}' from release {utils.Color.bold(version)}")
|
|
430
|
+
click.echo()
|
|
431
|
+
click.echo("This will:")
|
|
432
|
+
click.echo(f" • Remove patch from {version}-patches.toml")
|
|
433
|
+
click.echo(f" • Move directory to Patches/orphaned/{patch_id}/")
|
|
434
|
+
click.echo(f" • Keep git branch ho-patch/{patch_id}")
|
|
435
|
+
click.echo()
|
|
436
|
+
if not click.confirm("Continue?", default=True):
|
|
437
|
+
click.echo("Cancelled.")
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
# Execute detach
|
|
441
|
+
result = repo.patch_manager.detach_patch(patch_id)
|
|
442
|
+
|
|
443
|
+
click.echo()
|
|
444
|
+
click.echo(utils.Color.green(f"✓ Patch '{patch_id}' detached from release {version}"))
|
|
445
|
+
click.echo(f" Directory moved to: {result['orphaned_path']}")
|
|
446
|
+
click.echo(f" Branch preserved: ho-patch/{patch_id}")
|
|
447
|
+
click.echo()
|
|
448
|
+
click.echo("To reattach later: half_orm dev release attach-patch <patch_id>")
|
|
449
|
+
|
|
450
|
+
except PatchManagerError as e:
|
|
451
|
+
click.echo(utils.Color.red(f"Error: {e}"), err=True)
|
|
452
|
+
raise click.Abort()
|
|
@@ -4,6 +4,7 @@ Release command group - Unified release management.
|
|
|
4
4
|
Groups all release-related commands under 'half_orm dev release':
|
|
5
5
|
- release create: Prepare next release stage file
|
|
6
6
|
- release promote: Promote stage to rc or production
|
|
7
|
+
- release attach-patch: Reattach orphaned patch to a release
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
import click
|
|
@@ -16,6 +17,7 @@ from half_orm_dev.release_manager import (
|
|
|
16
17
|
ReleaseFileError,
|
|
17
18
|
ReleaseVersionError
|
|
18
19
|
)
|
|
20
|
+
from half_orm_dev.patch_manager import PatchManagerError
|
|
19
21
|
from half_orm import utils
|
|
20
22
|
|
|
21
23
|
|
|
@@ -484,3 +486,65 @@ def release_apply(skip_tests: bool) -> None:
|
|
|
484
486
|
click.echo(f"❌ {utils.Color.red('Release apply failed:')}", err=True)
|
|
485
487
|
click.echo(f" {str(e)}", err=True)
|
|
486
488
|
sys.exit(1)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@release.command('attach-patch')
|
|
492
|
+
@click.argument('patch_id', type=str)
|
|
493
|
+
@click.option('--force', '-f', is_flag=True, help='Skip confirmation prompt')
|
|
494
|
+
def release_attach_patch(patch_id: str, force: bool) -> None:
|
|
495
|
+
"""
|
|
496
|
+
Reattach an orphaned patch to the current release.
|
|
497
|
+
|
|
498
|
+
Moves the patch from Patches/orphaned/ back to Patches/ and adds it
|
|
499
|
+
as a candidate in the release TOML file.
|
|
500
|
+
|
|
501
|
+
Must be run from a ho-release/X.Y.Z branch.
|
|
502
|
+
|
|
503
|
+
\b
|
|
504
|
+
EXAMPLES:
|
|
505
|
+
# Reattach an orphaned patch
|
|
506
|
+
half_orm dev release attach-patch 123-feature
|
|
507
|
+
|
|
508
|
+
# Skip confirmation
|
|
509
|
+
half_orm dev release attach-patch 123-feature --force
|
|
510
|
+
"""
|
|
511
|
+
try:
|
|
512
|
+
repo = Repo()
|
|
513
|
+
|
|
514
|
+
# Must be on ho-release/* branch
|
|
515
|
+
current_branch = repo.hgit.branch
|
|
516
|
+
if not current_branch.startswith('ho-release/'):
|
|
517
|
+
click.echo(
|
|
518
|
+
f"❌ {utils.Color.red('Must be on ho-release/X.Y.Z branch.')}",
|
|
519
|
+
err=True
|
|
520
|
+
)
|
|
521
|
+
click.echo(
|
|
522
|
+
f" Current branch: {current_branch}",
|
|
523
|
+
err=True
|
|
524
|
+
)
|
|
525
|
+
sys.exit(1)
|
|
526
|
+
|
|
527
|
+
version = current_branch.replace('ho-release/', '')
|
|
528
|
+
|
|
529
|
+
# Confirmation
|
|
530
|
+
if not force:
|
|
531
|
+
click.echo(f"Attaching patch '{patch_id}' to release {version}")
|
|
532
|
+
click.echo("This will:")
|
|
533
|
+
click.echo(f" • Add patch to {version}-patches.toml as candidate")
|
|
534
|
+
click.echo(f" • Move directory from Patches/orphaned/{patch_id}/ to Patches/{patch_id}/")
|
|
535
|
+
if not click.confirm("Continue?", default=True):
|
|
536
|
+
click.echo("Cancelled.")
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
# Execute attach
|
|
540
|
+
result = repo.patch_manager.attach_patch(patch_id, version)
|
|
541
|
+
|
|
542
|
+
click.echo(utils.Color.green(
|
|
543
|
+
f"✓ Patch '{patch_id}' attached to release {version}"
|
|
544
|
+
))
|
|
545
|
+
click.echo(f" Status: candidate")
|
|
546
|
+
click.echo(f" Directory: {result['patch_path']}")
|
|
547
|
+
|
|
548
|
+
except PatchManagerError as e:
|
|
549
|
+
click.echo(f"❌ {utils.Color.red(str(e))}", err=True)
|
|
550
|
+
sys.exit(1)
|
|
@@ -4,6 +4,7 @@ Decorators for half-orm-dev.
|
|
|
4
4
|
Provides common decorators for ReleaseManager and PatchManager.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import signal
|
|
7
8
|
import sys
|
|
8
9
|
import inspect
|
|
9
10
|
from functools import wraps
|
|
@@ -13,9 +14,6 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
|
|
|
13
14
|
"""
|
|
14
15
|
Decorator to protect methods with a dynamic branch lock.
|
|
15
16
|
|
|
16
|
-
Unlike with_branch_lock which uses a static branch name, this decorator
|
|
17
|
-
calls a function to determine the branch name at runtime.
|
|
18
|
-
|
|
19
17
|
IMPORTANT: Automatically syncs .hop/ directory to all other active branches
|
|
20
18
|
after the decorated method completes (from locked branch to all others).
|
|
21
19
|
|
|
@@ -77,8 +75,21 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
|
|
|
77
75
|
return result
|
|
78
76
|
finally:
|
|
79
77
|
# Always release lock (even on error)
|
|
78
|
+
# Block SIGINT during cleanup to prevent Ctrl+C from
|
|
79
|
+
# interrupting lock release and leaving an orphan tag.
|
|
80
80
|
if lock_tag:
|
|
81
|
-
|
|
81
|
+
interrupted = False
|
|
82
|
+
original_handler = signal.getsignal(signal.SIGINT)
|
|
83
|
+
signal.signal(signal.SIGINT, lambda s, f: setattr(
|
|
84
|
+
wrapper, '_interrupted', True) or None)
|
|
85
|
+
wrapper._interrupted = False
|
|
86
|
+
try:
|
|
87
|
+
self._repo.hgit.release_branch_lock(lock_tag)
|
|
88
|
+
finally:
|
|
89
|
+
interrupted = wrapper._interrupted
|
|
90
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
91
|
+
if interrupted:
|
|
92
|
+
raise KeyboardInterrupt()
|
|
82
93
|
|
|
83
94
|
return wrapper
|
|
84
95
|
return decorator
|
|
@@ -2033,11 +2033,15 @@ class PatchManager:
|
|
|
2033
2033
|
# 5. Merge patch branch into release branch
|
|
2034
2034
|
try:
|
|
2035
2035
|
self._repo.hgit.merge(patch_branch, message=f'''[HOP] Merge #{patch_id} into %"{version}"''')
|
|
2036
|
-
except Exception as e:
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
)
|
|
2036
|
+
except (GitCommandError, Exception) as e:
|
|
2037
|
+
# Auto-resolve conflicts in generated files (package dir).
|
|
2038
|
+
# Generated files are regenerated by modules.generate() so
|
|
2039
|
+
# conflicts there can be safely auto-resolved.
|
|
2040
|
+
if not self._auto_resolve_generated_conflicts(e, patch_branch, release_branch):
|
|
2041
|
+
raise PatchManagerError(
|
|
2042
|
+
f"Failed to merge {patch_branch} into {release_branch}: {e}\n"
|
|
2043
|
+
f"You may need to resolve conflicts manually."
|
|
2044
|
+
)
|
|
2041
2045
|
|
|
2042
2046
|
# 5b. Get merge commit hash
|
|
2043
2047
|
merge_commit = self._repo.hgit.last_commit()
|
|
@@ -2212,6 +2216,55 @@ class PatchManager:
|
|
|
2212
2216
|
# Return to original branch
|
|
2213
2217
|
self._repo.hgit.checkout(original_branch)
|
|
2214
2218
|
|
|
2219
|
+
def _auto_resolve_generated_conflicts(
|
|
2220
|
+
self,
|
|
2221
|
+
merge_error: Exception,
|
|
2222
|
+
patch_branch: str,
|
|
2223
|
+
target_branch: str = None
|
|
2224
|
+
) -> bool:
|
|
2225
|
+
"""
|
|
2226
|
+
Auto-resolve merge conflicts when they only affect generated files.
|
|
2227
|
+
|
|
2228
|
+
Generated files (under the package directory) are regenerated by
|
|
2229
|
+
modules.generate() after patch apply, so conflicts there are harmless
|
|
2230
|
+
and can be safely resolved by accepting either side.
|
|
2231
|
+
|
|
2232
|
+
Args:
|
|
2233
|
+
merge_error: The exception from the failed merge
|
|
2234
|
+
patch_branch: The patch branch being merged
|
|
2235
|
+
target_branch: Optional target branch name (for error messages)
|
|
2236
|
+
|
|
2237
|
+
Returns:
|
|
2238
|
+
True if conflicts were auto-resolved, False otherwise
|
|
2239
|
+
"""
|
|
2240
|
+
package_name = self._repo.name
|
|
2241
|
+
git = self._repo.hgit._HGit__git_repo.git
|
|
2242
|
+
|
|
2243
|
+
try:
|
|
2244
|
+
diff_output = git.diff('--name-only', '--diff-filter=U')
|
|
2245
|
+
if not isinstance(diff_output, str) or not diff_output.strip():
|
|
2246
|
+
return False
|
|
2247
|
+
conflicted = diff_output.strip().splitlines()
|
|
2248
|
+
except Exception:
|
|
2249
|
+
return False
|
|
2250
|
+
|
|
2251
|
+
non_generated = [
|
|
2252
|
+
f for f in conflicted
|
|
2253
|
+
if not f.startswith(f"{package_name}/")
|
|
2254
|
+
]
|
|
2255
|
+
|
|
2256
|
+
if non_generated:
|
|
2257
|
+
return False
|
|
2258
|
+
|
|
2259
|
+
# All conflicts are in generated files — auto-resolve
|
|
2260
|
+
# and conclude the interrupted merge commit.
|
|
2261
|
+
click.echo(f" • Auto-resolving conflicts in generated files...")
|
|
2262
|
+
for f in conflicted:
|
|
2263
|
+
git.checkout('--theirs', f)
|
|
2264
|
+
git.add(*conflicted)
|
|
2265
|
+
git.commit('--no-verify', '--no-edit')
|
|
2266
|
+
return True
|
|
2267
|
+
|
|
2215
2268
|
def _validate_patch_before_merge(
|
|
2216
2269
|
self,
|
|
2217
2270
|
patch_id: str,
|
|
@@ -2267,11 +2320,15 @@ class PatchManager:
|
|
|
2267
2320
|
click.echo(f" • Merging {patch_branch} into temp branch...")
|
|
2268
2321
|
try:
|
|
2269
2322
|
self._repo.hgit.merge(patch_branch, message=f"[VALIDATE] Test merge #{patch_id}")
|
|
2270
|
-
except Exception as e:
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
)
|
|
2323
|
+
except (GitCommandError, Exception) as e:
|
|
2324
|
+
# Auto-resolve conflicts in generated files (package dir).
|
|
2325
|
+
# Generated files are regenerated by modules.generate() so
|
|
2326
|
+
# conflicts there can be safely auto-resolved.
|
|
2327
|
+
if not self._auto_resolve_generated_conflicts(e, patch_branch):
|
|
2328
|
+
raise PatchManagerError(
|
|
2329
|
+
f"Failed to merge {patch_branch} during validation: {e}\n"
|
|
2330
|
+
f"Please resolve conflicts before closing the patch."
|
|
2331
|
+
)
|
|
2275
2332
|
|
|
2276
2333
|
# 3. Run patch apply and verify no modifications
|
|
2277
2334
|
click.echo(f" • Running patch apply to verify idempotency...")
|
|
@@ -2308,18 +2365,31 @@ class PatchManager:
|
|
|
2308
2365
|
# Generate modules
|
|
2309
2366
|
modules.generate(self._repo)
|
|
2310
2367
|
|
|
2311
|
-
#
|
|
2368
|
+
# Stage generated files (package dir) — they are always
|
|
2369
|
+
# regenerated from DB state so changes there are expected.
|
|
2370
|
+
package_name = self._repo.name
|
|
2371
|
+
package_dir = Path(self._repo.base_dir) / package_name
|
|
2372
|
+
if package_dir.exists():
|
|
2373
|
+
self._repo.hgit.add(str(package_dir))
|
|
2374
|
+
|
|
2375
|
+
# Check if any NON-generated files were modified
|
|
2312
2376
|
if not self._repo.hgit.repos_is_clean():
|
|
2313
2377
|
modified_files = self._repo.hgit.get_modified_files()
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
f
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2378
|
+
# Filter out generated files (already staged)
|
|
2379
|
+
non_generated = [
|
|
2380
|
+
f for f in modified_files
|
|
2381
|
+
if not f.startswith(f"{package_name}/")
|
|
2382
|
+
]
|
|
2383
|
+
if non_generated:
|
|
2384
|
+
raise PatchManagerError(
|
|
2385
|
+
f"Patch validation failed: patch apply modified files!\n"
|
|
2386
|
+
f"This indicates the patch is not idempotent or schema is out of sync.\n\n"
|
|
2387
|
+
f"Modified files:\n" + "\n".join(f" • {f}" for f in non_generated) + "\n\n"
|
|
2388
|
+
f"Actions required:\n"
|
|
2389
|
+
f" 1. Verify patch SQL is idempotent (uses CREATE IF NOT EXISTS, etc.)\n"
|
|
2390
|
+
f" 2. Ensure schema.sql is up to date with all previous patches\n"
|
|
2391
|
+
f" 3. Run 'half_orm dev patch apply' on your patch branch to test"
|
|
2392
|
+
)
|
|
2323
2393
|
|
|
2324
2394
|
click.echo(f" • {utils.Color.green('✓')} Patch apply succeeded with no modifications")
|
|
2325
2395
|
|
|
@@ -2361,6 +2431,15 @@ class PatchManager:
|
|
|
2361
2431
|
finally:
|
|
2362
2432
|
# 6. Cleanup: Delete temp branch and return to original branch
|
|
2363
2433
|
try:
|
|
2434
|
+
# Discard any staged/unstaged changes in the generated package
|
|
2435
|
+
# dir so checkout doesn't fail (they were staged during validation).
|
|
2436
|
+
package_name = self._repo.name
|
|
2437
|
+
try:
|
|
2438
|
+
self._repo.hgit._HGit__git_repo.git.reset('HEAD', '--', f'{package_name}/')
|
|
2439
|
+
self._repo.hgit._HGit__git_repo.git.checkout('--', f'{package_name}/')
|
|
2440
|
+
except Exception:
|
|
2441
|
+
pass
|
|
2442
|
+
|
|
2364
2443
|
# Return to original branch
|
|
2365
2444
|
if self._repo.hgit.branch != original_branch:
|
|
2366
2445
|
self._repo.hgit.checkout(original_branch)
|
|
@@ -3005,6 +3084,152 @@ class PatchManager:
|
|
|
3005
3084
|
f"Failed to move patch to staged: {e}"
|
|
3006
3085
|
)
|
|
3007
3086
|
|
|
3087
|
+
def detach_patch(self, patch_id: str) -> dict:
|
|
3088
|
+
"""
|
|
3089
|
+
Detach a candidate patch from its release (move to orphaned/).
|
|
3090
|
+
|
|
3091
|
+
Workflow:
|
|
3092
|
+
1. Validate patch is candidate (not staged)
|
|
3093
|
+
2. Remove patch from TOML file
|
|
3094
|
+
3. Move directory to Patches/orphaned/
|
|
3095
|
+
4. Keep git branch as-is (for future reattachment)
|
|
3096
|
+
5. Update cache
|
|
3097
|
+
6. Commit and sync changes
|
|
3098
|
+
|
|
3099
|
+
Args:
|
|
3100
|
+
patch_id: Patch identifier to detach
|
|
3101
|
+
|
|
3102
|
+
Returns:
|
|
3103
|
+
dict with 'patch_id', 'version', 'orphaned_path'
|
|
3104
|
+
|
|
3105
|
+
Raises:
|
|
3106
|
+
PatchManagerError: If patch not found, not candidate, or operation fails
|
|
3107
|
+
"""
|
|
3108
|
+
# 1. Get patch status
|
|
3109
|
+
status_map = self.get_patch_status_map()
|
|
3110
|
+
if patch_id not in status_map:
|
|
3111
|
+
raise PatchManagerError(f"Patch '{patch_id}' not found")
|
|
3112
|
+
|
|
3113
|
+
patch_info = status_map[patch_id]
|
|
3114
|
+
status = patch_info.get("status")
|
|
3115
|
+
version = patch_info.get("version")
|
|
3116
|
+
|
|
3117
|
+
# 2. Validate: only candidates can be detached
|
|
3118
|
+
if status == "staged":
|
|
3119
|
+
raise PatchManagerError(
|
|
3120
|
+
f"Cannot detach staged patch '{patch_id}'. "
|
|
3121
|
+
"Only candidate patches can be detached."
|
|
3122
|
+
)
|
|
3123
|
+
if status == "orphaned":
|
|
3124
|
+
raise PatchManagerError(f"Patch '{patch_id}' is already orphaned.")
|
|
3125
|
+
|
|
3126
|
+
# 3. Remove from TOML
|
|
3127
|
+
release_file = ReleaseFile(version, self._releases_dir)
|
|
3128
|
+
release_file.remove_patch(patch_id)
|
|
3129
|
+
self._repo.hgit.add(str(release_file.file_path))
|
|
3130
|
+
|
|
3131
|
+
# 4. Move directory to orphaned/
|
|
3132
|
+
old_path = self.get_patch_directory_path(patch_id)
|
|
3133
|
+
orphaned_dir = self._schema_patches_dir / "orphaned"
|
|
3134
|
+
new_path = orphaned_dir / patch_id
|
|
3135
|
+
|
|
3136
|
+
if old_path.exists():
|
|
3137
|
+
orphaned_dir.mkdir(exist_ok=True)
|
|
3138
|
+
self._repo.hgit.mv(str(old_path), str(new_path))
|
|
3139
|
+
|
|
3140
|
+
# 5. Update cache
|
|
3141
|
+
self._update_patch_status_cache(patch_id, "orphaned")
|
|
3142
|
+
|
|
3143
|
+
# 6. Commit changes (include Patches/ directory in sync)
|
|
3144
|
+
self._repo.commit_and_sync_to_active_branches(
|
|
3145
|
+
message=f"[HOP] detach patch #{patch_id} from release %{version}",
|
|
3146
|
+
files=['Patches/']
|
|
3147
|
+
)
|
|
3148
|
+
|
|
3149
|
+
return {
|
|
3150
|
+
'patch_id': patch_id,
|
|
3151
|
+
'version': version,
|
|
3152
|
+
'orphaned_path': str(new_path)
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
def attach_patch(self, patch_id: str, version: str) -> dict:
|
|
3156
|
+
"""
|
|
3157
|
+
Attach an orphaned patch to a release (move from orphaned/ to root).
|
|
3158
|
+
|
|
3159
|
+
Reverse of detach_patch(). Workflow:
|
|
3160
|
+
1. Validate patch is orphaned
|
|
3161
|
+
2. Validate target release exists (TOML file)
|
|
3162
|
+
3. Add patch to TOML as candidate
|
|
3163
|
+
4. Move directory from Patches/orphaned/ to Patches/
|
|
3164
|
+
5. Update cache
|
|
3165
|
+
6. Commit and sync changes
|
|
3166
|
+
|
|
3167
|
+
Args:
|
|
3168
|
+
patch_id: Patch identifier to attach
|
|
3169
|
+
version: Target release version (e.g., "0.1.0")
|
|
3170
|
+
|
|
3171
|
+
Returns:
|
|
3172
|
+
dict with 'patch_id', 'version', 'patch_path'
|
|
3173
|
+
|
|
3174
|
+
Raises:
|
|
3175
|
+
PatchManagerError: If patch not found, not orphaned, or release doesn't exist
|
|
3176
|
+
"""
|
|
3177
|
+
# 1. Get patch status
|
|
3178
|
+
status_map = self.get_patch_status_map()
|
|
3179
|
+
if patch_id not in status_map:
|
|
3180
|
+
raise PatchManagerError(f"Patch '{patch_id}' not found")
|
|
3181
|
+
|
|
3182
|
+
patch_info = status_map[patch_id]
|
|
3183
|
+
status = patch_info.get("status")
|
|
3184
|
+
|
|
3185
|
+
# 2. Validate: only orphaned patches can be attached
|
|
3186
|
+
if status == "candidate":
|
|
3187
|
+
raise PatchManagerError(
|
|
3188
|
+
f"Patch '{patch_id}' is already a candidate in release "
|
|
3189
|
+
f"{patch_info.get('version')}."
|
|
3190
|
+
)
|
|
3191
|
+
if status == "staged":
|
|
3192
|
+
raise PatchManagerError(
|
|
3193
|
+
f"Cannot attach staged patch '{patch_id}'. "
|
|
3194
|
+
"Staged patches are already integrated."
|
|
3195
|
+
)
|
|
3196
|
+
if status != "orphaned":
|
|
3197
|
+
raise PatchManagerError(
|
|
3198
|
+
f"Patch '{patch_id}' has unexpected status '{status}'."
|
|
3199
|
+
)
|
|
3200
|
+
|
|
3201
|
+
# 3. Validate release exists and add to TOML
|
|
3202
|
+
release_file = ReleaseFile(version, self._releases_dir)
|
|
3203
|
+
if not release_file.file_path.exists():
|
|
3204
|
+
raise PatchManagerError(
|
|
3205
|
+
f"Release {version} not found. "
|
|
3206
|
+
f"No file {release_file.file_path.name} exists."
|
|
3207
|
+
)
|
|
3208
|
+
release_file.add_patch(patch_id)
|
|
3209
|
+
self._repo.hgit.add(str(release_file.file_path))
|
|
3210
|
+
|
|
3211
|
+
# 4. Move directory from orphaned/ to root
|
|
3212
|
+
orphaned_path = self._schema_patches_dir / "orphaned" / patch_id
|
|
3213
|
+
new_path = self._schema_patches_dir / patch_id
|
|
3214
|
+
|
|
3215
|
+
if orphaned_path.exists():
|
|
3216
|
+
self._repo.hgit.mv(str(orphaned_path), str(new_path))
|
|
3217
|
+
|
|
3218
|
+
# 5. Update cache
|
|
3219
|
+
self._update_patch_status_cache(patch_id, "candidate")
|
|
3220
|
+
|
|
3221
|
+
# 6. Commit changes (include Patches/ directory in sync)
|
|
3222
|
+
self._repo.commit_and_sync_to_active_branches(
|
|
3223
|
+
message=f"[HOP] attach patch #{patch_id} to release %{version}",
|
|
3224
|
+
files=['Patches/']
|
|
3225
|
+
)
|
|
3226
|
+
|
|
3227
|
+
return {
|
|
3228
|
+
'patch_id': patch_id,
|
|
3229
|
+
'version': version,
|
|
3230
|
+
'patch_path': str(new_path)
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3008
3233
|
def _get_other_candidates(self, version: str, exclude_patch: str) -> List[str]:
|
|
3009
3234
|
"""
|
|
3010
3235
|
Get list of other candidate patches for a release (excluding one).
|