half-orm-dev 0.16.0a9__py3-none-any.whl

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 (58) hide show
  1. half_orm_dev/__init__.py +1 -0
  2. half_orm_dev/cli/__init__.py +9 -0
  3. half_orm_dev/cli/commands/__init__.py +56 -0
  4. half_orm_dev/cli/commands/apply.py +13 -0
  5. half_orm_dev/cli/commands/clone.py +102 -0
  6. half_orm_dev/cli/commands/init.py +331 -0
  7. half_orm_dev/cli/commands/new.py +15 -0
  8. half_orm_dev/cli/commands/patch.py +317 -0
  9. half_orm_dev/cli/commands/prepare.py +21 -0
  10. half_orm_dev/cli/commands/prepare_release.py +119 -0
  11. half_orm_dev/cli/commands/promote_to.py +127 -0
  12. half_orm_dev/cli/commands/release.py +344 -0
  13. half_orm_dev/cli/commands/restore.py +14 -0
  14. half_orm_dev/cli/commands/sync.py +13 -0
  15. half_orm_dev/cli/commands/todo.py +73 -0
  16. half_orm_dev/cli/commands/undo.py +17 -0
  17. half_orm_dev/cli/commands/update.py +73 -0
  18. half_orm_dev/cli/commands/upgrade.py +191 -0
  19. half_orm_dev/cli/main.py +103 -0
  20. half_orm_dev/cli_extension.py +38 -0
  21. half_orm_dev/database.py +1389 -0
  22. half_orm_dev/hgit.py +1025 -0
  23. half_orm_dev/hop.py +167 -0
  24. half_orm_dev/manifest.py +43 -0
  25. half_orm_dev/modules.py +456 -0
  26. half_orm_dev/patch.py +281 -0
  27. half_orm_dev/patch_manager.py +1694 -0
  28. half_orm_dev/patch_validator.py +335 -0
  29. half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +34 -0
  30. half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +2 -0
  31. half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +3 -0
  32. half_orm_dev/patches/log +2 -0
  33. half_orm_dev/patches/sql/half_orm_meta.sql +208 -0
  34. half_orm_dev/release_manager.py +2841 -0
  35. half_orm_dev/repo.py +1562 -0
  36. half_orm_dev/templates/.gitignore +15 -0
  37. half_orm_dev/templates/MANIFEST.in +1 -0
  38. half_orm_dev/templates/Pipfile +13 -0
  39. half_orm_dev/templates/README +25 -0
  40. half_orm_dev/templates/conftest_template +42 -0
  41. half_orm_dev/templates/init_module_template +10 -0
  42. half_orm_dev/templates/module_template_1 +12 -0
  43. half_orm_dev/templates/module_template_2 +6 -0
  44. half_orm_dev/templates/module_template_3 +3 -0
  45. half_orm_dev/templates/relation_test +23 -0
  46. half_orm_dev/templates/setup.py +81 -0
  47. half_orm_dev/templates/sql_adapter +9 -0
  48. half_orm_dev/templates/warning +12 -0
  49. half_orm_dev/utils.py +49 -0
  50. half_orm_dev/version.txt +1 -0
  51. half_orm_dev-0.16.0a9.dist-info/METADATA +935 -0
  52. half_orm_dev-0.16.0a9.dist-info/RECORD +58 -0
  53. half_orm_dev-0.16.0a9.dist-info/WHEEL +5 -0
  54. half_orm_dev-0.16.0a9.dist-info/licenses/AUTHORS +3 -0
  55. half_orm_dev-0.16.0a9.dist-info/licenses/LICENSE +14 -0
  56. half_orm_dev-0.16.0a9.dist-info/top_level.txt +2 -0
  57. tests/__init__.py +0 -0
  58. tests/conftest.py +329 -0
half_orm_dev/hgit.py ADDED
@@ -0,0 +1,1025 @@
1
+ "Provides the HGit class"
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import subprocess
8
+ import git
9
+ from git.exc import GitCommandError
10
+ from typing import List
11
+
12
+ from half_orm import utils
13
+ from half_orm_dev.manifest import Manifest
14
+
15
+ class HGit:
16
+ "Manages the git operations on the repo."
17
+ def __init__(self, repo=None):
18
+ self.__origin = None
19
+ self.__repo = repo
20
+ self.__base_dir = None
21
+ self.__git_repo: git.Repo = None
22
+ if repo:
23
+ self.__origin = repo.git_origin
24
+ self.__base_dir = repo.base_dir
25
+ self.__post_init()
26
+
27
+ def __post_init(self):
28
+ """
29
+ Initialize HGit for existing repository.
30
+
31
+ Verifies that Git remote origin matches configuration.
32
+ For new projects, remote is configured during init().
33
+
34
+ Raises:
35
+ SystemExit: If no remote configured or remote mismatch detected
36
+ """
37
+ self.__git_repo = git.Repo(self.__base_dir)
38
+
39
+ # Verify remote origin is configured
40
+ try:
41
+ git_remote_origin = self.__git_repo.git.remote('get-url', 'origin')
42
+ except Exception:
43
+ # No remote origin configured - this is an error
44
+ utils.error(
45
+ "❌ Git remote origin not configured!\n\n"
46
+ "half_orm_dev requires a Git remote for patch management.\n"
47
+ "The remote origin is used for:\n"
48
+ " • Patch ID reservation (via tags)\n"
49
+ " • Branch synchronization (ho-patch branches)\n"
50
+ " • Collaborative development workflow\n\n"
51
+ "To fix this, add a remote origin:\n"
52
+ f" cd {self.__base_dir}\n"
53
+ " git remote add origin <your-git-url>\n"
54
+ " git push -u origin ho-prod\n\n"
55
+ "Or update .hop/config with the correct git_origin.",
56
+ exit_code=1
57
+ )
58
+
59
+ # Verify remote matches configuration
60
+ if self.__origin != git_remote_origin:
61
+ utils.error(
62
+ f"❌ Git remote origin mismatch detected!\n\n"
63
+ f"Configuration (.hop/config): {self.__origin}\n"
64
+ f"Git remote (git remote -v): {git_remote_origin}\n\n"
65
+ "This mismatch can cause issues with patch management.\n\n"
66
+ "To fix this, choose one:\n\n"
67
+ "Option 1: Update Git remote to match config\n"
68
+ f" cd {self.__base_dir}\n"
69
+ f" git remote set-url origin {self.__origin}\n\n"
70
+ "Option 2: Update config to match Git remote\n"
71
+ f" Edit {self.__base_dir}/.hop/config\n"
72
+ f" Set: git_origin = {git_remote_origin}",
73
+ exit_code=1
74
+ )
75
+
76
+ self.__current_branch = self.branch
77
+
78
+ def __str__(self):
79
+ res = ['[Git]']
80
+ res.append(f'- origin: {self.__origin or utils.Color.red("No origin")}')
81
+ res.append(f'- current branch: {self.__current_branch}')
82
+ clean = self.repos_is_clean()
83
+ clean = utils.Color.green(clean) \
84
+ if clean else utils.Color.red(clean)
85
+ res.append(f'- repo is clean: {clean}')
86
+ res.append(f'- last commit: {self.last_commit()}')
87
+ return '\n'.join(res)
88
+
89
+ def init(self, base_dir, git_origin):
90
+ "Initializes the git repo."
91
+ cur_dir = os.path.abspath(os.path.curdir)
92
+ self.__base_dir = base_dir
93
+ try:
94
+ git.Repo.init(base_dir)
95
+ self.__git_repo = git.Repo(base_dir)
96
+ os.chdir(base_dir)
97
+
98
+ # Create ho-prod branch FIRST (before any commits)
99
+ self.__git_repo.git.checkout('-b', 'ho-prod')
100
+
101
+ # Then add files and commit on ho-prod
102
+ self.__git_repo.git.add('.')
103
+ self.__git_repo.git.remote('add', 'origin', git_origin)
104
+ self.__git_repo.git.commit(m=f'[ho] Initial commit (release: 0.0.0)')
105
+ self.__git_repo.git.push('--set-upstream', 'origin', 'ho-prod')
106
+ os.chdir(cur_dir)
107
+ except GitCommandError as err:
108
+ utils.error(
109
+ f'Something went wrong initializing git repo in {base_dir}\n{err}\n', exit_code=1)
110
+ return self
111
+
112
+ @property
113
+ def branch(self):
114
+ "Returns the active branch"
115
+ return str(self.__git_repo.active_branch)
116
+
117
+ def current_branch(self):
118
+ "Returns the active branch"
119
+ return str(self.__git_repo.active_branch)
120
+
121
+ @property
122
+ def current_release(self):
123
+ "Returns the current branch name without 'hop_'"
124
+ return self.branch.replace('hop_', '')
125
+
126
+ @property
127
+ def is_hop_patch_branch(self):
128
+ "Returns True if we are on a hop patch branch hop_X.Y.Z."
129
+ try:
130
+ major, minor, patch = self.current_release.split('.')
131
+ return bool(1 + int(major) + int(minor) + int(patch))
132
+ except ValueError:
133
+ return False
134
+
135
+ def repos_is_clean(self):
136
+ "Returns True if the git repository is clean, False otherwise."
137
+ return not self.__git_repo.is_dirty(untracked_files=True)
138
+
139
+ def last_commit(self):
140
+ """Returns the last commit
141
+ """
142
+ commit = str(list(self.__git_repo.iter_commits(self.branch, max_count=1))[0])[0:8]
143
+ assert self.__git_repo.head.commit.hexsha[0:8] == commit
144
+ return commit
145
+
146
+ def branch_exists(self, branch):
147
+ "Returns True if branch is in branches"
148
+ return branch in self.__git_repo.heads
149
+
150
+ def set_branch(self, release_s):
151
+ """
152
+ LEGACY METHOD - No longer supported
153
+
154
+ Branch management for releases removed in v0.16.0.
155
+ Use new patch-centric workflow with PatchManager.
156
+ """
157
+ raise NotImplementedError(
158
+ "Legacy branch-per-release system removed in v0.16.0. "
159
+ "Use new patch-centric workflow via repo.patch_manager"
160
+ )
161
+
162
+ def cherry_pick_changelog(self, release_s):
163
+ "Sync CHANGELOG on all hop_x.y.z branches in devel different from release_s"
164
+ raise Exception("Deprecated legacy cherry_pick_changelog")
165
+ branch = self.__git_repo.active_branch
166
+ self.__git_repo.git.checkout('hop_main')
167
+ commit_sha = self.__git_repo.head.commit.hexsha[0:8]
168
+ for release in self.__repo.changelog.releases_in_dev:
169
+ if release != release_s:
170
+ self.__git_repo.git.checkout(f'hop_{release}')
171
+ self.__git_repo.git.cherry_pick(commit_sha)
172
+ # self.__git_repo.git.commit('--amend', '-m', f'[hop][{release_s}] CHANGELOG')
173
+ self.__git_repo.git.checkout(branch)
174
+
175
+ def rebase_devel_branches(self, release_s):
176
+ "Rebase all hop_x.y.z branches in devel different from release_s on hop_main:HEAD"
177
+ raise Exception("Deprecated legacy rebase_devel_branches")
178
+ for release in self.__repo.changelog.releases_in_dev:
179
+ if release != release_s:
180
+ self.__git_repo.git.checkout(f'hop_{release}')
181
+ self.__git_repo.git.rebase('hop_main')
182
+
183
+ def check_rebase_hop_main(self, current_branch):
184
+ raise Exception("Deprecated legacy check_rebase_hop_main")
185
+ git = self.__git_repo.git
186
+ try:
187
+ git.branch("-D", "hop_temp")
188
+ except GitCommandError:
189
+ pass
190
+ for release in self.__repo.changelog.releases_in_dev:
191
+ git.checkout(f'hop_{release}')
192
+ git.checkout("HEAD", b="hop_temp")
193
+ try:
194
+ git.rebase('hop_main')
195
+ except GitCommandError as exc:
196
+ git.rebase('--abort')
197
+ git.checkout(current_branch)
198
+ utils.error(f"Can't rebase {release} on hop_main.\n{exc}\n", exit_code=1)
199
+ git.checkout(current_branch)
200
+ git.branch("-D", "hop_temp")
201
+
202
+ def rebase_to_hop_main(self, push=False):
203
+ """
204
+ LEGACY METHOD - No longer supported
205
+
206
+ Release rebasing removed in v0.16.0.
207
+ """
208
+ raise NotImplementedError(
209
+ "Legacy release rebasing removed in v0.16.0. "
210
+ "Use new patch-centric workflow"
211
+ )
212
+
213
+ def add(self, *args, **kwargs):
214
+ "Proxy to git.add method"
215
+ return self.__git_repo.git.add(*args, **kwargs)
216
+
217
+ def commit(self, *args, **kwargs):
218
+ "Proxy to git.commit method"
219
+ return self.__git_repo.git.commit(*args, **kwargs)
220
+
221
+ def rebase(self, *args, **kwargs):
222
+ "Proxy to git.commit method"
223
+ return self.__git_repo.git.rebase(*args, **kwargs)
224
+
225
+ def checkout(self, *args, **kwargs):
226
+ "Proxy to git.commit method"
227
+ return self.__git_repo.git.checkout(*args, **kwargs)
228
+
229
+ def pull(self, *args, **kwargs):
230
+ "Proxy to git.pull method"
231
+ return self.__git_repo.git.pull(*args, **kwargs)
232
+
233
+ def push(self, *args, **kwargs):
234
+ "Proxy to git.push method"
235
+ return self.__git_repo.git.push(*args, **kwargs)
236
+
237
+ def merge(self, *args, **kwargs):
238
+ "Proxy to git.merge method"
239
+ return self.__git_repo.git.merge(*args, **kwargs)
240
+
241
+ def mv(self, *args, **kwargs):
242
+ "Proxy to git.mv method"
243
+ return self.__git_repo.git.mv(*args, **kwargs)
244
+
245
+ def checkout_to_hop_main(self):
246
+ "Checkout to hop_main branch"
247
+ self.__git_repo.git.checkout('hop_main')
248
+
249
+ def has_remote(self) -> bool:
250
+ """
251
+ Check if git remote 'origin' is configured.
252
+
253
+ Returns:
254
+ bool: True if origin remote exists, False otherwise
255
+
256
+ Examples:
257
+ if hgit.has_remote():
258
+ print("Remote configured")
259
+ else:
260
+ print("No remote - local repo only")
261
+ """
262
+ try:
263
+ # Check if any remotes exist
264
+ remotes = self.__git_repo.remotes
265
+
266
+ # Look specifically for 'origin' remote
267
+ for remote in remotes:
268
+ if remote.name == 'origin':
269
+ return True
270
+
271
+ return False
272
+ except Exception:
273
+ # Gracefully handle any git errors
274
+ return False
275
+
276
+ def push_branch(self, branch_name: str, set_upstream: bool = True) -> None:
277
+ """
278
+ Push branch to remote origin.
279
+
280
+ Pushes specified branch to origin remote, optionally setting
281
+ upstream tracking. Used for global patch ID reservation.
282
+
283
+ Args:
284
+ branch_name: Branch name to push (e.g., "ho-patch/456-user-auth")
285
+ set_upstream: If True, set upstream tracking with -u flag
286
+
287
+ Raises:
288
+ GitCommandError: If push fails (no remote, auth issues, etc.)
289
+
290
+ Examples:
291
+ # Push with upstream tracking
292
+ hgit.push_branch("ho-patch/456-user-auth")
293
+
294
+ # Push without upstream tracking
295
+ hgit.push_branch("ho-patch/456-user-auth", set_upstream=False)
296
+ """
297
+ # Get origin remote
298
+ origin = self.__git_repo.remote('origin')
299
+
300
+ # Push branch with or without upstream tracking
301
+ origin.push(branch_name, set_upstream=set_upstream)
302
+
303
+ def fetch_tags(self) -> None:
304
+ """
305
+ Fetch all tags from remote.
306
+
307
+ Updates local knowledge of remote tags for patch number reservation.
308
+
309
+ Raises:
310
+ GitCommandError: If fetch fails
311
+
312
+ Examples:
313
+ hgit.fetch_tags()
314
+ # Local git now knows about all remote tags
315
+ """
316
+ try:
317
+ origin = self.__git_repo.remote('origin')
318
+ origin.fetch(tags=True)
319
+ except Exception as e:
320
+ from git.exc import GitCommandError
321
+ if isinstance(e, GitCommandError):
322
+ raise
323
+ raise GitCommandError(f"git fetch --tags", 1, stderr=str(e))
324
+
325
+ def tag_exists(self, tag_name: str) -> bool:
326
+ """
327
+ Check if tag exists locally or on remote.
328
+
329
+ Args:
330
+ tag_name: Tag name to check (e.g., "ho-patch/456")
331
+
332
+ Returns:
333
+ bool: True if tag exists, False otherwise
334
+
335
+ Examples:
336
+ if hgit.tag_exists("ho-patch/456"):
337
+ print("Patch number 456 reserved")
338
+ """
339
+ try:
340
+ # Check in local tags
341
+ return tag_name in [tag.name for tag in self.__git_repo.tags]
342
+ except Exception:
343
+ return False
344
+
345
+ def create_tag(self, tag_name: str, message: str) -> None:
346
+ """
347
+ Create annotated tag for patch number reservation.
348
+
349
+ Args:
350
+ tag_name: Tag name (e.g., "ho-patch/456")
351
+ message: Tag message/description
352
+
353
+ Raises:
354
+ GitCommandError: If tag creation fails
355
+
356
+ Examples:
357
+ hgit.create_tag("ho-patch/456", "Patch 456: User authentication")
358
+ """
359
+ try:
360
+ self.__git_repo.create_tag(tag_name, message=message)
361
+ except Exception as e:
362
+ from git.exc import GitCommandError
363
+ if isinstance(e, GitCommandError):
364
+ raise
365
+ raise GitCommandError(f"git tag", 1, stderr=str(e))
366
+
367
+ def push_tag(self, tag_name: str) -> None:
368
+ """
369
+ Push tag to remote for global reservation.
370
+
371
+ Args:
372
+ tag_name: Tag name to push (e.g., "ho-patch/456")
373
+
374
+ Raises:
375
+ GitCommandError: If push fails
376
+
377
+ Examples:
378
+ hgit.push_tag("ho-patch/456")
379
+ """
380
+ origin = self.__git_repo.remote('origin')
381
+ origin.push(tag_name)
382
+
383
+ def fetch_from_origin(self) -> None:
384
+ """
385
+ Fetch all references from origin remote.
386
+
387
+ Updates local knowledge of all remote references including:
388
+ - All remote branches
389
+ - All remote tags
390
+ - Other remote refs
391
+
392
+ This is more comprehensive than fetch_tags() which only fetches tags.
393
+ Used before patch creation to ensure up-to-date view of remote state.
394
+
395
+ Raises:
396
+ GitCommandError: If fetch fails (no remote, network, auth, etc.)
397
+
398
+ Examples:
399
+ hgit.fetch_from_origin()
400
+ # Local git now has complete up-to-date view of origin
401
+ """
402
+ try:
403
+ origin = self.__git_repo.remote('origin')
404
+ origin.fetch()
405
+ except Exception as e:
406
+ from git.exc import GitCommandError
407
+ if isinstance(e, GitCommandError):
408
+ raise
409
+ raise GitCommandError(f"git fetch origin", 1, stderr=str(e))
410
+
411
+ def delete_local_branch(self, branch_name: str) -> None:
412
+ """
413
+ Delete local branch.
414
+
415
+ Args:
416
+ branch_name: Branch name to delete (e.g., "ho-patch/456-user-auth")
417
+
418
+ Raises:
419
+ GitCommandError: If deletion fails
420
+
421
+ Examples:
422
+ hgit.delete_local_branch("ho-patch/456-user-auth")
423
+ # Branch deleted locally
424
+ """
425
+ try:
426
+ self.__git_repo.git.branch('-D', branch_name)
427
+ except Exception as e:
428
+ from git.exc import GitCommandError
429
+ if isinstance(e, GitCommandError):
430
+ raise
431
+ raise GitCommandError(f"git branch -D {branch_name}", 1, stderr=str(e))
432
+
433
+
434
+ def delete_local_tag(self, tag_name: str) -> None:
435
+ """
436
+ Delete local tag.
437
+
438
+ Args:
439
+ tag_name: Tag name to delete (e.g., "ho-patch/456")
440
+
441
+ Raises:
442
+ GitCommandError: If deletion fails
443
+
444
+ Examples:
445
+ hgit.delete_local_tag("ho-patch/456")
446
+ # Tag deleted locally
447
+ """
448
+ try:
449
+ self.__git_repo.git.tag('-d', tag_name)
450
+ except Exception as e:
451
+ from git.exc import GitCommandError
452
+ if isinstance(e, GitCommandError):
453
+ raise
454
+ raise GitCommandError(f"git tag -d {tag_name}", 1, stderr=str(e))
455
+
456
+ def get_local_commit_hash(self, branch_name: str) -> str:
457
+ """
458
+ Get the commit hash of a local branch.
459
+
460
+ Retrieves the SHA-1 hash of the HEAD commit for the specified
461
+ local branch. Used to compare local state with remote state.
462
+
463
+ Args:
464
+ branch_name: Local branch name (e.g., "ho-prod", "ho-patch/456")
465
+
466
+ Returns:
467
+ str: Full SHA-1 commit hash (40 characters)
468
+
469
+ Raises:
470
+ GitCommandError: If branch doesn't exist locally
471
+
472
+ Examples:
473
+ # Get commit hash of ho-prod
474
+ hash_prod = hgit.get_local_commit_hash("ho-prod")
475
+ print(f"Local ho-prod at: {hash_prod[:8]}")
476
+
477
+ # Get commit hash of patch branch
478
+ hash_patch = hgit.get_local_commit_hash("ho-patch/456")
479
+ """
480
+ try:
481
+ # Access branch from heads
482
+ if branch_name not in self.__git_repo.heads:
483
+ raise GitCommandError(
484
+ f"git rev-parse {branch_name}",
485
+ 1,
486
+ stderr=f"Branch '{branch_name}' not found locally"
487
+ )
488
+
489
+ branch = self.__git_repo.heads[branch_name]
490
+ return branch.commit.hexsha
491
+
492
+ except GitCommandError:
493
+ raise
494
+ except Exception as e:
495
+ raise GitCommandError(
496
+ f"git rev-parse {branch_name}",
497
+ 1,
498
+ stderr=str(e)
499
+ )
500
+
501
+ def get_remote_commit_hash(self, branch_name: str, remote: str = 'origin') -> str:
502
+ """
503
+ Get the commit hash of a remote branch.
504
+
505
+ Retrieves the SHA-1 hash of the HEAD commit for the specified
506
+ branch on the remote repository. Requires prior fetch to have
507
+ up-to-date information.
508
+
509
+ Args:
510
+ branch_name: Branch name (e.g., "ho-prod", "ho-patch/456")
511
+ remote: Remote name (default: "origin")
512
+
513
+ Returns:
514
+ str: Full SHA-1 commit hash (40 characters)
515
+
516
+ Raises:
517
+ GitCommandError: If remote or branch doesn't exist on remote
518
+
519
+ Examples:
520
+ # Get remote commit hash (after fetch)
521
+ hgit.fetch_from_origin()
522
+ hash_remote = hgit.get_remote_commit_hash("ho-prod")
523
+ print(f"Remote ho-prod at: {hash_remote[:8]}")
524
+
525
+ # Compare with local
526
+ hash_local = hgit.get_local_commit_hash("ho-prod")
527
+ if hash_local == hash_remote:
528
+ print("Branch is synced")
529
+ """
530
+ try:
531
+ # Get remote
532
+ remote_obj = self.__git_repo.remote(remote)
533
+
534
+ # Check if branch exists in remote refs
535
+ if branch_name not in remote_obj.refs:
536
+ raise GitCommandError(
537
+ f"git ls-remote {remote} {branch_name}",
538
+ 1,
539
+ stderr=f"Branch '{branch_name}' not found on remote '{remote}'"
540
+ )
541
+
542
+ # Get commit hash from remote ref
543
+ remote_ref = remote_obj.refs[branch_name]
544
+ return remote_ref.commit.hexsha
545
+
546
+ except GitCommandError:
547
+ raise
548
+ except Exception as e:
549
+ raise GitCommandError(
550
+ f"git ls-remote {remote} {branch_name}",
551
+ 1,
552
+ stderr=str(e)
553
+ )
554
+
555
+ def is_branch_synced(self, branch_name: str, remote: str = 'origin') -> tuple[bool, str]:
556
+ """
557
+ Check if local branch is synchronized with remote branch.
558
+
559
+ Compares local and remote commit hashes to determine sync status.
560
+ Returns both a boolean indicating if synced and a status message.
561
+
562
+ Requires fetch_from_origin() to be called first for accurate results.
563
+
564
+ Sync states:
565
+ - "synced": Local and remote at same commit
566
+ - "ahead": Local has commits not on remote (need push)
567
+ - "behind": Remote has commits not in local (need pull)
568
+ - "diverged": Both have different commits (need merge/rebase)
569
+
570
+ Args:
571
+ branch_name: Branch name to check (e.g., "ho-prod")
572
+ remote: Remote name (default: "origin")
573
+
574
+ Returns:
575
+ tuple[bool, str]: (is_synced, status_message)
576
+ - is_synced: True only if "synced", False otherwise
577
+ - status_message: One of "synced", "ahead", "behind", "diverged"
578
+
579
+ Raises:
580
+ GitCommandError: If branch doesn't exist locally or on remote
581
+
582
+ Examples:
583
+ # Basic sync check
584
+ hgit.fetch_from_origin()
585
+ is_synced, status = hgit.is_branch_synced("ho-prod")
586
+
587
+ if is_synced:
588
+ print("✅ ho-prod is synced with origin")
589
+ else:
590
+ print(f"⚠️ ho-prod is {status}")
591
+ if status == "behind":
592
+ print("Run: git pull")
593
+ elif status == "ahead":
594
+ print("Run: git push")
595
+ elif status == "diverged":
596
+ print("Run: git pull --rebase or git merge")
597
+
598
+ # Use in validation
599
+ def validate_branch_synced(branch):
600
+ is_synced, status = hgit.is_branch_synced(branch)
601
+ if not is_synced:
602
+ raise ValidationError(
603
+ f"Branch {branch} is {status}. "
604
+ f"Sync required before creating patch."
605
+ )
606
+ """
607
+ # Get local and remote commit hashes
608
+ local_hash = self.get_local_commit_hash(branch_name)
609
+ remote_hash = self.get_remote_commit_hash(branch_name, remote)
610
+
611
+ # If hashes are identical, branches are synced
612
+ if local_hash == remote_hash:
613
+ return (True, "synced")
614
+
615
+ # Branches differ - determine if ahead, behind, or diverged
616
+ try:
617
+ # Get merge base (common ancestor)
618
+ local_commit = self.__git_repo.heads[branch_name].commit
619
+ remote_ref = self.__git_repo.remote(remote).refs[branch_name]
620
+ remote_commit = remote_ref.commit
621
+
622
+ merge_base_commits = self.__git_repo.merge_base(local_commit, remote_commit)
623
+
624
+ if not merge_base_commits:
625
+ # No common ancestor - diverged
626
+ return (False, "diverged")
627
+
628
+ merge_base_hash = merge_base_commits[0].hexsha
629
+
630
+ # Compare merge base with local and remote
631
+ if merge_base_hash == remote_hash:
632
+ # Merge base = remote → local is ahead
633
+ return (False, "ahead")
634
+ elif merge_base_hash == local_hash:
635
+ # Merge base = local → local is behind
636
+ return (False, "behind")
637
+ else:
638
+ # Merge base different from both → diverged
639
+ return (False, "diverged")
640
+
641
+ except Exception as e:
642
+ # If merge_base fails, assume diverged
643
+ return (False, "diverged")
644
+
645
+ def acquire_branch_lock(self, branch_name: str, timeout_minutes: int = 30) -> str:
646
+ """
647
+ Acquire exclusive lock on branch using Git tag.
648
+
649
+ Creates lock tag with format: lock-{branch}-{utc_timestamp_ms}
650
+ Only one process can hold lock on a branch at a time.
651
+
652
+ Automatically cleans up stale locks (older than timeout).
653
+
654
+ Args:
655
+ branch_name: Branch to lock (e.g., "ho-prod", "ho-patch/456")
656
+ timeout_minutes: Consider lock stale after this many minutes (default: 30)
657
+
658
+ Returns:
659
+ Lock tag name (e.g., "lock-ho-prod-1704123456789")
660
+
661
+ Raises:
662
+ GitCommandError: If lock acquisition fails
663
+
664
+ Examples:
665
+ # Lock ho-prod for release operations
666
+ lock_tag = hgit.acquire_branch_lock("ho-prod")
667
+ try:
668
+ # ... do work on ho-prod ...
669
+ finally:
670
+ hgit.release_branch_lock(lock_tag)
671
+
672
+ # Lock with custom timeout
673
+ lock_tag = hgit.acquire_branch_lock("ho-prod", timeout_minutes=60)
674
+ """
675
+ import time
676
+ import re
677
+ from datetime import datetime, timedelta
678
+
679
+ # Sanitize branch name for tag (replace / with -)
680
+ safe_branch_name = branch_name.replace('/', '-')
681
+
682
+ # Fetch latest tags
683
+ self.fetch_tags()
684
+
685
+ # Check for existing locks on this branch
686
+ lock_pattern = f"lock-{safe_branch_name}-*"
687
+ existing_locks = self.list_tags(pattern=lock_pattern)
688
+
689
+ if existing_locks:
690
+ # Extract timestamp from first lock
691
+ match = re.search(r'-(\d+)$', existing_locks[0])
692
+ if match:
693
+ lock_timestamp_ms = int(match.group(1))
694
+ lock_time = datetime.utcfromtimestamp(lock_timestamp_ms / 1000.0)
695
+ current_time = datetime.utcnow()
696
+
697
+ # Check if lock is stale
698
+ age_minutes = (current_time - lock_time).total_seconds() / 60
699
+
700
+ if age_minutes > timeout_minutes:
701
+ # Stale lock - delete it
702
+ print(f"⚠️ Cleaning up stale lock: {existing_locks[0]} (age: {age_minutes:.1f} min)")
703
+ try:
704
+ self.__git_repo.git.push("origin", "--delete", existing_locks[0])
705
+ self.delete_local_tag(existing_locks[0])
706
+ except Exception as e:
707
+ print(f"Warning: Failed to delete stale lock: {e}")
708
+ # Continue to create new lock
709
+ else:
710
+ # Recent lock - respect it
711
+ from git.exc import GitCommandError
712
+ raise GitCommandError(
713
+ f"Branch '{branch_name}' is locked by another process.\n"
714
+ f"Lock: {existing_locks[0]}\n"
715
+ f"Age: {age_minutes:.1f} minutes\n"
716
+ f"Wait a few minutes and retry, or manually delete the lock tag if the process died.",
717
+ status=1
718
+ )
719
+
720
+ # Create new lock with UTC timestamp in milliseconds
721
+ timestamp_ms = int(time.time() * 1000)
722
+ lock_tag = f"lock-{safe_branch_name}-{timestamp_ms}"
723
+
724
+ # Create local tag
725
+ self.create_tag(lock_tag, message=f"Lock on {branch_name} at {datetime.utcnow().isoformat()}")
726
+
727
+ # Push tag (ATOMIC - this is the lock acquisition)
728
+ try:
729
+ self.push_tag(lock_tag)
730
+ except Exception as e:
731
+ # Push failed - someone else got the lock first
732
+ # Cleanup local tag
733
+ self.delete_local_tag(lock_tag)
734
+
735
+ from git.exc import GitCommandError
736
+ raise GitCommandError(
737
+ f"Failed to acquire lock on '{branch_name}'.\n"
738
+ f"Another process acquired it first.\n"
739
+ f"Retry in a few seconds.",
740
+ status=1
741
+ )
742
+
743
+ return lock_tag
744
+
745
+
746
+ def release_branch_lock(self, lock_tag: str) -> None:
747
+ """
748
+ Release branch lock by deleting lock tag.
749
+
750
+ Always called in finally block to ensure cleanup.
751
+ Non-fatal if deletion fails (logs warning).
752
+
753
+ Args:
754
+ lock_tag: Lock tag name to release (e.g., "lock-ho-prod-1704123456789")
755
+
756
+ Examples:
757
+ lock_tag = hgit.acquire_branch_lock("ho-prod")
758
+ try:
759
+ # ... work ...
760
+ finally:
761
+ hgit.release_branch_lock(lock_tag)
762
+ """
763
+ # Best effort - continue even if fails
764
+ try:
765
+ # Delete remote tag
766
+ self.__git_repo.git.push("origin", "--delete", lock_tag)
767
+ except Exception as e:
768
+ print(f"⚠️ Warning: Failed to delete remote lock tag {lock_tag}: {e}")
769
+
770
+ try:
771
+ # Delete local tag
772
+ self.delete_local_tag(lock_tag)
773
+ except Exception as e:
774
+ print(f"⚠️ Warning: Failed to delete local lock tag {lock_tag}: {e}")
775
+
776
+
777
+ def list_tags(self, pattern: Optional[str] = None) -> List[str]:
778
+ """
779
+ List tags matching glob pattern.
780
+
781
+ Args:
782
+ pattern: Optional glob pattern (e.g., "lock-*", "lock-ho-prod-*")
783
+
784
+ Returns:
785
+ List of tag names matching pattern
786
+
787
+ Examples:
788
+ # List all locks
789
+ locks = hgit.list_tags("lock-*")
790
+
791
+ # List locks on specific branch
792
+ ho_prod_locks = hgit.list_tags("lock-ho-prod-*")
793
+ """
794
+ import fnmatch
795
+
796
+ all_tags = [tag.name for tag in self.__git_repo.tags]
797
+
798
+ if pattern:
799
+ return [tag for tag in all_tags if fnmatch.fnmatch(tag, pattern)]
800
+
801
+ return all_tags
802
+
803
+ def rename_branch(
804
+ self,
805
+ old_name: str,
806
+ new_name: str,
807
+ delete_remote_old: bool = True
808
+ ) -> None:
809
+ """
810
+ Rename local and remote branch atomically.
811
+
812
+ Performs complete branch rename workflow: creates new branch from old,
813
+ pushes new to remote, deletes old from remote and local. Used by
814
+ ReleaseManager to archive patch branches after integration.
815
+
816
+ Workflow:
817
+ 1. Fetch latest from origin (ensure we have latest state)
818
+ 2. Create new local branch from old branch (preserves history)
819
+ 3. Push new branch to remote with upstream tracking
820
+ 4. Delete old branch from remote (if delete_remote_old=True)
821
+ 5. Delete old local branch
822
+
823
+ Args:
824
+ old_name: Current branch name (e.g., "ho-patch/456-user-auth")
825
+ new_name: New branch name (e.g., "ho-release/1.3.6/456-user-auth")
826
+ delete_remote_old: If True, delete old branch on remote
827
+ If False, keep old branch on remote (for backup)
828
+
829
+ Raises:
830
+ GitCommandError: If branch operations fail:
831
+ - Old branch doesn't exist
832
+ - New branch already exists
833
+ - Push/delete operations fail
834
+ - Remote access issues
835
+
836
+ Examples:
837
+ # Archive patch branch after integration
838
+ hgit.rename_branch(
839
+ "ho-patch/456-user-auth",
840
+ "ho-release/1.3.6/456-user-auth"
841
+ )
842
+ # Result:
843
+ # - Local: ho-patch/456 deleted, ho-release/1.3.6/456 created
844
+ # - Remote: ho-patch/456 deleted, ho-release/1.3.6/456 created
845
+
846
+ # Restore patch branch from archive
847
+ hgit.rename_branch(
848
+ "ho-release/1.3.6/456-user-auth",
849
+ "ho-patch/456-user-auth"
850
+ )
851
+ # Result: Branch restored to active development namespace
852
+
853
+ # Rename without deleting old remote (keep backup)
854
+ hgit.rename_branch(
855
+ "ho-patch/456-user-auth",
856
+ "ho-release/1.3.6/456-user-auth",
857
+ delete_remote_old=False
858
+ )
859
+ # Result: Both branches exist on remote (old + new)
860
+
861
+ Notes:
862
+ - Complete Git history is preserved (new branch points to same commits)
863
+ - If old branch is currently checked out, operation fails
864
+ - Upstream tracking is automatically set for new branch
865
+ - Remote operations may fail due to network or permissions
866
+ """
867
+ # 1. Fetch latest from origin to ensure we have up-to-date refs
868
+ try:
869
+ self.fetch_from_origin()
870
+ except GitCommandError as e:
871
+ raise GitCommandError(
872
+ f"Failed to fetch from origin before rename: {e}",
873
+ status=1
874
+ )
875
+
876
+ # 2. Check if old branch exists (local or remote)
877
+ old_branch_exists_local = old_name in self.__git_repo.heads
878
+ old_branch_exists_remote = False
879
+
880
+ try:
881
+ origin = self.__git_repo.remote('origin')
882
+ old_branch_exists_remote = old_name in [
883
+ ref.name.replace('origin/', '', 1)
884
+ for ref in origin.refs
885
+ ]
886
+ except:
887
+ pass # Remote may not exist or may not have the branch
888
+
889
+ if not old_branch_exists_local and not old_branch_exists_remote:
890
+ raise GitCommandError(
891
+ f"Branch '{old_name}' does not exist locally or on remote",
892
+ status=1
893
+ )
894
+
895
+ # 3. Check if new branch already exists
896
+ new_branch_exists_local = new_name in self.__git_repo.heads
897
+ new_branch_exists_remote = False
898
+
899
+ try:
900
+ origin = self.__git_repo.remote('origin')
901
+ new_branch_exists_remote = new_name in [
902
+ ref.name.replace('origin/', '', 1)
903
+ for ref in origin.refs
904
+ ]
905
+ except:
906
+ pass
907
+
908
+ if new_branch_exists_local or new_branch_exists_remote:
909
+ raise GitCommandError(
910
+ f"Branch '{new_name}' already exists. Cannot rename.",
911
+ status=1
912
+ )
913
+
914
+ # 4. Create new local branch from old branch
915
+ # If old branch only exists on remote, create from remote ref
916
+ if not old_branch_exists_local and old_branch_exists_remote:
917
+ # Create from remote ref
918
+ try:
919
+ self.__git_repo.git.branch(new_name, f"origin/{old_name}")
920
+ except GitCommandError as e:
921
+ raise GitCommandError(
922
+ f"Failed to create new branch '{new_name}' from remote '{old_name}': {e}",
923
+ status=1
924
+ )
925
+ else:
926
+ # Create from local branch
927
+ try:
928
+ self.__git_repo.git.branch(new_name, old_name)
929
+ except GitCommandError as e:
930
+ raise GitCommandError(
931
+ f"Failed to create new branch '{new_name}' from local '{old_name}': {e}",
932
+ status=1
933
+ )
934
+
935
+ # 5. Push new branch to remote with upstream tracking
936
+ try:
937
+ origin = self.__git_repo.remote('origin')
938
+ origin.push(f"{new_name}:{new_name}", set_upstream=True)
939
+ except GitCommandError as e:
940
+ # Rollback: delete local new branch
941
+ try:
942
+ self.__git_repo.git.branch("-D", new_name)
943
+ except:
944
+ pass # Best effort
945
+
946
+ raise GitCommandError(
947
+ f"Failed to push new branch '{new_name}' to remote: {e}",
948
+ status=1
949
+ )
950
+
951
+ # 6. Delete old branch from remote (if requested)
952
+ if delete_remote_old and old_branch_exists_remote:
953
+ try:
954
+ origin = self.__git_repo.remote('origin')
955
+ origin.push(refspec=f":{old_name}") # Delete remote branch
956
+ except GitCommandError as e:
957
+ # Non-fatal: log warning but continue
958
+ # New branch is already on remote, which is the main goal
959
+ import sys
960
+ print(
961
+ f"Warning: Failed to delete old remote branch '{old_name}': {e}",
962
+ file=sys.stderr
963
+ )
964
+
965
+ # 7. Delete old local branch (if exists and not currently checked out)
966
+ if old_branch_exists_local:
967
+ # Check if old branch is currently checked out
968
+ current_branch = str(self.__git_repo.active_branch)
969
+
970
+ if current_branch == old_name:
971
+ # Cannot delete currently checked out branch
972
+ # This is expected behavior - caller should checkout another branch first
973
+ raise GitCommandError(
974
+ f"Cannot delete branch '{old_name}' while it is checked out. "
975
+ f"Checkout another branch first.",
976
+ status=1
977
+ )
978
+
979
+ try:
980
+ self.__git_repo.git.branch("-D", old_name)
981
+ except GitCommandError as e:
982
+ # Non-fatal: new branch exists, which is the main goal
983
+ import sys
984
+ print(
985
+ f"Warning: Failed to delete old local branch '{old_name}': {e}",
986
+ file=sys.stderr
987
+ )
988
+
989
+ def get_remote_branches(self) -> List[str]:
990
+ """
991
+ Get list of all remote branches.
992
+
993
+ Returns:
994
+ List of remote branch names with 'origin/' prefix
995
+ Example: ['origin/ho-prod', 'origin/ho-patch/456-user-auth']
996
+
997
+ Examples:
998
+ branches = hgit.get_remote_branches()
999
+ # → ['origin/ho-prod', 'origin/ho-patch/456', 'origin/ho-patch/789']
1000
+
1001
+ # Filter for patch branches
1002
+ patch_branches = [b for b in branches if 'ho-patch' in b]
1003
+ """
1004
+ try:
1005
+ result = subprocess.run(
1006
+ ["git", "branch", "-r"],
1007
+ cwd=self.__base_dir,
1008
+ capture_output=True,
1009
+ text=True,
1010
+ check=True
1011
+ )
1012
+
1013
+ # Parse output: each line is a branch name
1014
+ branches = []
1015
+ for line in result.stdout.strip().split('\n'):
1016
+ branch = line.strip()
1017
+ # Skip empty lines and HEAD references
1018
+ if branch and not 'HEAD' in branch:
1019
+ branches.append(branch)
1020
+
1021
+ return branches
1022
+
1023
+ except subprocess.CalledProcessError:
1024
+ # If command fails, return empty list
1025
+ return []