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.
- half_orm_dev/__init__.py +1 -0
- half_orm_dev/cli/__init__.py +9 -0
- half_orm_dev/cli/commands/__init__.py +56 -0
- half_orm_dev/cli/commands/apply.py +13 -0
- half_orm_dev/cli/commands/clone.py +102 -0
- half_orm_dev/cli/commands/init.py +331 -0
- half_orm_dev/cli/commands/new.py +15 -0
- half_orm_dev/cli/commands/patch.py +317 -0
- half_orm_dev/cli/commands/prepare.py +21 -0
- half_orm_dev/cli/commands/prepare_release.py +119 -0
- half_orm_dev/cli/commands/promote_to.py +127 -0
- half_orm_dev/cli/commands/release.py +344 -0
- half_orm_dev/cli/commands/restore.py +14 -0
- half_orm_dev/cli/commands/sync.py +13 -0
- half_orm_dev/cli/commands/todo.py +73 -0
- half_orm_dev/cli/commands/undo.py +17 -0
- half_orm_dev/cli/commands/update.py +73 -0
- half_orm_dev/cli/commands/upgrade.py +191 -0
- half_orm_dev/cli/main.py +103 -0
- half_orm_dev/cli_extension.py +38 -0
- half_orm_dev/database.py +1389 -0
- half_orm_dev/hgit.py +1025 -0
- half_orm_dev/hop.py +167 -0
- half_orm_dev/manifest.py +43 -0
- half_orm_dev/modules.py +456 -0
- half_orm_dev/patch.py +281 -0
- half_orm_dev/patch_manager.py +1694 -0
- half_orm_dev/patch_validator.py +335 -0
- half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +34 -0
- half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +2 -0
- half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +3 -0
- half_orm_dev/patches/log +2 -0
- half_orm_dev/patches/sql/half_orm_meta.sql +208 -0
- half_orm_dev/release_manager.py +2841 -0
- half_orm_dev/repo.py +1562 -0
- half_orm_dev/templates/.gitignore +15 -0
- half_orm_dev/templates/MANIFEST.in +1 -0
- half_orm_dev/templates/Pipfile +13 -0
- half_orm_dev/templates/README +25 -0
- half_orm_dev/templates/conftest_template +42 -0
- half_orm_dev/templates/init_module_template +10 -0
- half_orm_dev/templates/module_template_1 +12 -0
- half_orm_dev/templates/module_template_2 +6 -0
- half_orm_dev/templates/module_template_3 +3 -0
- half_orm_dev/templates/relation_test +23 -0
- half_orm_dev/templates/setup.py +81 -0
- half_orm_dev/templates/sql_adapter +9 -0
- half_orm_dev/templates/warning +12 -0
- half_orm_dev/utils.py +49 -0
- half_orm_dev/version.txt +1 -0
- half_orm_dev-0.16.0a9.dist-info/METADATA +935 -0
- half_orm_dev-0.16.0a9.dist-info/RECORD +58 -0
- half_orm_dev-0.16.0a9.dist-info/WHEEL +5 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/AUTHORS +3 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/LICENSE +14 -0
- half_orm_dev-0.16.0a9.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- 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 []
|