Skip to content

Commit 96af9cb

Browse files
authored
Merge pull request #4 from alalkamys/fix/push-changes
Fix: Push Changes on Consecutive Runs to Achieve Idempotency
2 parents 498f6c3 + 2315722 commit 96af9cb

File tree

3 files changed

+165
-50
lines changed

3 files changed

+165
-50
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ Below is an explanation of each field in the configuration file:
292292
| `AZURE_DEVOPS_PAT` | Azure DevOps Personal Access Token (PAT) | `None` |
293293
| `GITHUB_TOKEN` | GitHub Personal Access Token (PAT) | `None` |
294294
| `GITHUB_ENTERPRISE_TOKEN` | GitHub Enterprise Personal Access Token (PAT) | `None` |
295-
| `CODE_MIGRATION_ASSISTANT_USER_AGENT` | User agent used for HTTP requests by Code Migration Assistant | `alalkamys`/code-migration-assistant |
295+
| `CODE_MIGRATION_ASSISTANT_USER_AGENT` | User agent used for HTTP requests by Code Migration Assistant | `alalkamys/code-migration-assistant` |
296296

297297
<!--********************* R E F E R E N C E S *********************-->
298298

app/helpers/git.py

Lines changed: 152 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33

44
from git import Actor
55
from git import Repo
6+
from git.exc import GitCommandError
7+
from git.exc import NoSuchPathError
68
from git.refs.head import Head
79
from git.remote import PushInfo
810
from git.remote import PushInfoList
9-
from git.exc import GitCommandError
10-
from git.exc import NoSuchPathError
1111
from typing import Any
12+
from typing import Union
1213
import json
1314
import logging
1415
import os
16+
import re
1517
import sys
1618

1719
_logger = logging.getLogger(app_config.APP_NAME)
@@ -133,7 +135,7 @@ def load_target_repos(repos: list[dict]) -> list[Repo]:
133135
return result
134136

135137

136-
def identity_setup(repo: Repo, actor_username: str, actor_email: str) -> None:
138+
def identity_setup(repo: Repo, actor_username: str, actor_email: str) -> bool:
137139
"""Set up identity configuration for a GitPython repository.
138140
139141
Args:
@@ -158,37 +160,91 @@ def identity_setup(repo: Repo, actor_username: str, actor_email: str) -> None:
158160
return False
159161

160162

161-
def checkout_branch(repo: Repo, branch_name: str, from_branch: str = None) -> bool:
162-
"""Checkout or create a new branch in the local repository.
163+
def configure_divergent_branches_reconciliation_method(repo: Repo, rebase: bool = False, fast_forward_only: bool = False) -> bool:
164+
"""Configure the reconciliation method for handling divergent branches in a GitPython repository.
165+
166+
This function configures the reconciliation method to handle situations where the local and remote branches have diverged during pull operations.
167+
168+
Args:
169+
repo (Repo): The GitPython repository object.
170+
rebase (bool, optional): If True, set the reconciliation method to rebase. Defaults to False.
171+
fast_forward_only (bool, optional): If True, set the reconciliation method to fast-forward only. Ignored if 'rebase' is True. Defaults to False.
172+
173+
Returns:
174+
bool: True if the reconciliation method was configured successfully, False otherwise.
175+
"""
176+
try:
177+
config_writer = repo.config_writer()
178+
if fast_forward_only:
179+
_logger.debug(
180+
"Setting reconciliation method to fast-forward only..")
181+
config_writer.set_value('pull', 'ff', 'only').release()
182+
elif rebase:
183+
_logger.debug("Setting reconciliation method to rebase..")
184+
config_writer.set_value('pull', 'rebase', 'true').release()
185+
else:
186+
_logger.debug("Setting reconciliation method to merge..")
187+
config_writer.set_value('pull', 'rebase', 'false').release()
188+
del (config_writer)
189+
return True
190+
except Exception as e:
191+
_logger.error(f"An error occurred while setting up reconciliation method: {
192+
str(e).strip()}")
193+
return False
194+
195+
196+
def checkout_branch(repo: Repo, branch_name: str, from_branch: str = None, remote_name: str = "origin") -> bool:
197+
"""Checkout an existing branch or create a new branch in the local repository.
198+
199+
This function checks out an existing branch or creates a new branch in the local repository.
200+
If the specified branch already exists locally, it switches to that branch.
201+
If the branch does not exist locally but exists in the remote repository, it creates a new local branch
202+
tracking the remote branch and switches to it.
203+
If the specified branch does not exist locally or remotely, it attempts to create a new branch
204+
based on the provided base branch (or the current branch if not specified).
163205
164206
Args:
165207
repo (Repo): The GitPython Repo object representing the local repository.
166208
branch_name (str): The name of the branch to checkout or create.
167209
from_branch (str, optional): The name of the base branch to create the new branch from.
168210
If None, create the new branch from the current branch. Defaults to None.
211+
remote_name (str, optional): The name of the remote repository. Defaults to "origin".
169212
170213
Returns:
171214
bool: True if the branch was successfully checked out or created, False otherwise.
172215
"""
173216
try:
217+
remote_branch_name = f"{remote_name}/{branch_name}"
174218
if branch_name in repo.branches:
175219
_logger.info(
176-
f"'{branch_name}' branch already exists, checking out..")
220+
f"'{branch_name}' branch already exists. Switching..")
177221
branch = repo.branches[branch_name]
222+
elif remote_branch_name in repo.refs:
223+
_logger.info(f"'{remote_branch_name}' exists.")
224+
branch = repo.create_head(branch_name, commit=remote_branch_name)
225+
_logger.info(f"Branch '{branch_name}' set up to track '{
226+
remote_branch_name}'")
227+
branch.set_tracking_branch(repo.refs[remote_branch_name])
178228
else:
179229
_logger.info(f"'{branch_name}' doesn't exist, creating..")
180-
from_branch = from_branch or repo.active_branch.name
230+
from_branch = from_branch or get_default_branch_name(repo=repo, remote_name=remote_name)
231+
remote_from_branch = f"{remote_name}/{from_branch}"
181232
if from_branch in repo.branches:
182233
branch = repo.create_head(branch_name, commit=from_branch)
183234
_logger.info(f"Created new branch '{
184-
branch_name}' based on '{from_branch}' branch")
235+
branch_name}' based on '{from_branch}' branch. Switching..")
236+
elif remote_from_branch in repo.refs:
237+
branch = repo.create_head(
238+
branch_name, commit=remote_from_branch)
239+
_logger.info(f"Created new branch '{
240+
branch_name}' based on '{remote_from_branch}' branch. Switching..")
185241
else:
186242
_logger.error(
187243
f"Error: '{from_branch}' based on branch doesn't exist")
188244
return False
189245

190246
branch.checkout()
191-
_logger.info(f"Checked out branch '{branch_name}' successfully.")
247+
_logger.info(f"Switched to branch '{branch_name}' successfully.")
192248
return True
193249

194250
except GitCommandError as e:
@@ -244,7 +300,7 @@ def commit_changes(repo: Repo, title: str, description: str = None, author: Acto
244300

245301

246302
def push_changes(repo: Repo, remote_name: str = 'origin', remote_branch_name: str | None = None, timeout: int | None = 180) -> bool:
247-
"""Push changes to the remote repository.
303+
"""Push changes to the remote repository, pulling changes from the remote branch if it exists.
248304
249305
Args:
250306
repo (Repo): The GitPython Repo object representing the local repository.
@@ -264,7 +320,8 @@ def push_changes(repo: Repo, remote_name: str = 'origin', remote_branch_name: st
264320
This function pushes changes from the active local branch to the specified remote branch.
265321
It handles various scenarios such as existing and non-existing remotes, and provides detailed logging
266322
information during the push operation. The timeout parameter allows customization of the maximum time
267-
allowed for the push operation.
323+
allowed for the push operation. Before pushing, if the specified remote branch exists, it pulls changes
324+
from that branch to ensure synchronization.
268325
269326
Example:
270327
# Push changes from the active branch to the 'main' branch of the remote repository 'origin'
@@ -276,44 +333,69 @@ def push_changes(repo: Repo, remote_name: str = 'origin', remote_branch_name: st
276333
remote = repo.remotes[remote_name]
277334
branch_name = repo.active_branch.name
278335
remote_branch_name = remote_branch_name if remote_branch_name else branch_name
279-
_logger.info(f"Pushing changes to '{
280-
remote_branch_name}' branch of remote '{remote_name}'...")
281-
result: PushInfoList = remote.push(
282-
refspec=f"{branch_name}:{remote_branch_name}", progress=RemoteProgressReporter(_logger), kill_after_timeout=timeout)
283-
try:
284-
assert len(result) != 0
285-
VALID_PUSH_INFO_FLAGS: list[int] = [PushInfo.FAST_FORWARD, PushInfo.NEW_HEAD,
286-
PushInfo.UP_TO_DATE, PushInfo.FORCED_UPDATE, PushInfo.NEW_TAG]
287-
for push_info in result:
288-
_logger.debug("+------------+")
289-
_logger.debug("| Push Info: |")
290-
_logger.debug("+------------+")
291-
_logger.debug(f"Flag: {push_info.flags}")
292-
_logger.debug(f"Local ref: {push_info.local_ref}")
293-
_logger.debug(f"Remote Ref: {push_info.remote_ref}")
294-
_logger.debug(f"Remote ref string: {
295-
push_info.remote_ref_string}")
296-
_logger.debug(f"Old Commit: {push_info.old_commit}")
297-
_logger.debug(f"Summary: {push_info.summary.strip()}")
298-
if push_info.flags not in VALID_PUSH_INFO_FLAGS:
299-
if push_info.flags == PushInfo.ERROR:
300-
_logger.error(
301-
f"Incomplete push error: Push contains rejected heads. Check your internet connection and run in 'debug' mode to see more details.")
302-
else:
303-
_logger.error(
304-
"Unexpected push error, maybe the remote rejected heads. Check your internet connection and run in 'debug' mode to see more details.")
305-
return False
306-
except AssertionError:
307-
_logger.error(f"Pushing changes to remote '{
308-
remote_name}' completely failed. Check your internet connection and run in 'debug' mode to see the remote push progress.")
309-
return False
310-
_logger.info(f"Changes pushed successfully to '{
311-
branch_name}' branch of remote '{remote_name}'.")
312-
313-
_logger.debug(f"Setting '{branch_name}' upstream branch to '{remote_name}/{
314-
remote_branch_name}'..")
315-
repo.active_branch.set_tracking_branch(
316-
repo.refs[f"{remote_name}/{remote_branch_name}"])
336+
337+
push_is_needed = True
338+
339+
remote_refs = remote.refs
340+
if remote_branch_name in remote_refs:
341+
_logger.debug(
342+
f"'{remote_name}/{remote_branch_name}' remote branch exists.")
343+
if not has_tracking_branch(repo.active_branch):
344+
_logger.debug(
345+
f"'{branch_name}' has no tracking branch. Setting..")
346+
repo.active_branch.set_tracking_branch(
347+
repo.refs[f"{remote_name}/{remote_branch_name}"])
348+
_logger.debug(f"Pulling changes from '{
349+
remote_branch_name}' branch of remote '{remote_name}' to '{branch_name}'...")
350+
remote.pull(
351+
refspec=remote_branch_name, kill_after_timeout=timeout)
352+
353+
push_is_needed = needs_push(repo=repo, branch_name=branch_name)
354+
355+
if push_is_needed:
356+
_logger.info(f"Pushing changes to '{
357+
remote_branch_name}' branch of remote '{remote_name}'...")
358+
result: PushInfoList = remote.push(
359+
refspec=f"{branch_name}:{remote_branch_name}", progress=RemoteProgressReporter(_logger), kill_after_timeout=timeout)
360+
361+
try:
362+
assert len(result) != 0
363+
VALID_PUSH_INFO_FLAGS: list[int] = [PushInfo.FAST_FORWARD, PushInfo.NEW_HEAD,
364+
PushInfo.UP_TO_DATE, PushInfo.FORCED_UPDATE, PushInfo.NEW_TAG]
365+
for push_info in result:
366+
_logger.debug("+------------+")
367+
_logger.debug("| Push Info: |")
368+
_logger.debug("+------------+")
369+
_logger.debug(f"Flag: {push_info.flags}")
370+
_logger.debug(f"Local ref: {push_info.local_ref}")
371+
_logger.debug(f"Remote Ref: {push_info.remote_ref}")
372+
_logger.debug(f"Remote ref string: {
373+
push_info.remote_ref_string}")
374+
_logger.debug(f"Old Commit: {push_info.old_commit}")
375+
_logger.debug(f"Summary: {push_info.summary.strip()}")
376+
if push_info.flags not in VALID_PUSH_INFO_FLAGS:
377+
if push_info.flags == PushInfo.ERROR:
378+
_logger.error(
379+
"Incomplete push error: Push contains rejected heads. Check your internet connection and run in 'debug' mode to see more details.")
380+
else:
381+
_logger.error(
382+
"Unexpected push error, maybe the remote rejected heads. Check your internet connection and run in 'debug' mode to see more details.")
383+
return False
384+
except AssertionError:
385+
_logger.error(f"Pushing changes to remote '{
386+
remote_name}' completely failed. Check your internet connection and run in 'debug' mode to see the remote push progress.")
387+
return False
388+
389+
_logger.info(f"Changes pushed successfully to '{
390+
branch_name}' branch of remote '{remote_name}'.")
391+
if not has_tracking_branch(repo.active_branch):
392+
_logger.debug(f"Setting '{branch_name}' upstream branch to '{
393+
remote_name}/{remote_branch_name}'..")
394+
repo.active_branch.set_tracking_branch(
395+
repo.refs[f"{remote_name}/{remote_branch_name}"])
396+
397+
else:
398+
_logger.info("Already up-to-date. Skipping..")
317399
return True
318400
except IndexError:
319401
_logger.error(f"Error accessing remote '{
@@ -377,3 +459,24 @@ def needs_push(repo: Repo, branch_name: str | None = None) -> bool:
377459
if tracking_branch:
378460
return any(repo.iter_commits(f"{tracking_branch.name}..{branch.name}"))
379461
return False
462+
463+
464+
def get_default_branch_name(repo: Repo, remote_name: str = "origin") -> Union[str, None]:
465+
"""Get the default branch name of a Git repository.
466+
467+
Args:
468+
repo (Repo): The GitPython Repo object representing the local repository.
469+
remote_name (str, optional): The name of the remote repository. Defaults to "origin".
470+
471+
Returns:
472+
Union[str, None]: The name of the default branch, or None if not found.
473+
"""
474+
try:
475+
show_result = repo.git.remote("show", remote_name)
476+
matches = re.search(r"\s*HEAD branch:\s*(.*)", show_result)
477+
if matches:
478+
return matches.group(1)
479+
except Exception as e:
480+
_logger.error(f"Error while querying the default branch: {e}")
481+
482+
return None

main.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from app.config import app_config
22
from app.helpers.git import checkout_branch
33
from app.helpers.git import commit_changes
4+
from app.helpers.git import configure_divergent_branches_reconciliation_method
45
from app.helpers.git import get_files_count
56
from app.helpers.git import has_tracking_branch
67
from app.helpers.git import identity_setup
@@ -82,6 +83,17 @@
8283
_logger.info("Exiting..")
8384
sys.exit(2)
8485

86+
reconciliation_method_configured = configure_divergent_branches_reconciliation_method(
87+
repo=repo, rebase=True)
88+
if not reconciliation_method_configured:
89+
_logger.error(
90+
f"Failed to configure the reconciliation method for '{repo_name}'. Review the logs for more details")
91+
if repo != TARGET_REPOS[-1]:
92+
_logger.info("Skipping to the next migration..")
93+
continue
94+
_logger.info("Exiting..")
95+
sys.exit(7)
96+
8597
if check_branch:
8698
branch_checked = checkout_branch(
8799
repo=repo, branch_name=TARGET_BRANCH['name'], from_branch=TARGET_BRANCH.get('from', None))

0 commit comments

Comments
 (0)