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.
Files changed (72) hide show
  1. {half_orm_dev-0.17.5a2/half_orm_dev.egg-info → half_orm_dev-0.17.5a4}/PKG-INFO +1 -1
  2. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/bootstrap_manager.py +41 -13
  3. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/check.py +8 -0
  4. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/patch.py +85 -0
  5. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/release.py +64 -0
  6. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/decorators.py +15 -4
  7. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patch_manager.py +245 -20
  8. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/release_manager.py +314 -330
  9. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/repo.py +50 -8
  10. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/git-hooks/pre-commit +3 -2
  11. half_orm_dev-0.17.5a4/half_orm_dev/version.txt +1 -0
  12. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4/half_orm_dev.egg-info}/PKG-INFO +1 -1
  13. half_orm_dev-0.17.5a2/half_orm_dev/version.txt +0 -1
  14. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/AUTHORS +0 -0
  15. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/LICENSE +0 -0
  16. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/README.md +0 -0
  17. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/__init__.py +0 -0
  18. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/__init__.py +0 -0
  19. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/__init__.py +0 -0
  20. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/apply.py +0 -0
  21. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/bootstrap.py +0 -0
  22. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/clone.py +0 -0
  23. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/init.py +0 -0
  24. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/migrate.py +0 -0
  25. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/restore.py +0 -0
  26. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/sync.py +0 -0
  27. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/todo.py +0 -0
  28. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/undo.py +0 -0
  29. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/update.py +0 -0
  30. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/commands/upgrade.py +0 -0
  31. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli/main.py +0 -0
  32. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/cli_extension.py +0 -0
  33. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/database.py +0 -0
  34. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/file_executor.py +0 -0
  35. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/hgit.py +0 -0
  36. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/migration_manager.py +0 -0
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {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
  43. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/modules.py +0 -0
  44. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patch_validator.py +0 -0
  45. {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
  46. {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
  47. {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
  48. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patches/log +0 -0
  49. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  50. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/release_file.py +0 -0
  51. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/scripts/repair-metadata.py +0 -0
  52. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/.gitignore +0 -0
  53. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/MANIFEST.in +0 -0
  54. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/README +0 -0
  55. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/conftest_template +0 -0
  56. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  57. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/init_module_template +0 -0
  58. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/module_template_1 +0 -0
  59. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/module_template_2 +0 -0
  60. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/module_template_3 +0 -0
  61. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/pyproject.toml +0 -0
  62. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/relation_test +0 -0
  63. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/sql_adapter +0 -0
  64. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/templates/warning +0 -0
  65. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev/utils.py +0 -0
  66. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev.egg-info/SOURCES.txt +0 -0
  67. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  68. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev.egg-info/requires.txt +0 -0
  69. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/half_orm_dev.egg-info/top_level.txt +0 -0
  70. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/pyproject.toml +0 -0
  71. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/setup.cfg +0 -0
  72. {half_orm_dev-0.17.5a2 → half_orm_dev-0.17.5a4}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 0.17.5a2
3
+ Version: 0.17.5a4
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
@@ -78,7 +78,12 @@ class BootstrapManager:
78
78
  # Schema might not exist yet, ignore
79
79
  pass
80
80
 
81
- def get_bootstrap_files(self, up_to_version: Optional[str] = None) -> List[Path]:
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
- Used during restore_database_from_schema to avoid executing
91
- bootstraps for future releases.
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
- # If up_to_version is specified, filter out files from newer versions
107
- if up_to_version:
108
- file_version = self._extract_version_from_filename(file_path.name)
109
- if file_version != 'unknown' and not self._version_le(file_version, up_to_version):
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(self, up_to_version: Optional[str] = None) -> List[Path]:
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
- self._repo.hgit.release_branch_lock(lock_tag)
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
- raise PatchManagerError(
2038
- f"Failed to merge {patch_branch} into {release_branch}: {e}\n"
2039
- f"You may need to resolve conflicts manually."
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
- raise PatchManagerError(
2272
- f"Failed to merge {patch_branch} during validation: {e}\n"
2273
- f"Please resolve conflicts before closing the patch."
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
- # Check if any files were modified
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
- raise PatchManagerError(
2315
- f"Patch validation failed: patch apply modified files!\n"
2316
- f"This indicates the patch is not idempotent or schema is out of sync.\n\n"
2317
- f"Modified files:\n" + "\n".join(f"{f}" for f in modified_files) + "\n\n"
2318
- f"Actions required:\n"
2319
- f" 1. Verify patch SQL is idempotent (uses CREATE IF NOT EXISTS, etc.)\n"
2320
- f" 2. Ensure schema.sql is up to date with all previous patches\n"
2321
- f" 3. Run 'half_orm dev patch apply' on your patch branch to test"
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).