# projectutility/core/git_manager.py import os import sys # Needed for path calculation and platform checks potentially import logging import shutil # For rmtree during cleanup from typing import Optional, Dict, Any, Tuple, Callable # --- Optional GitPython Import --- # Try importing GitPython and set a flag indicating its availability. try: import git # Specific exceptions and classes from GitPython from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError from git import RemoteProgress # For progress reporting during clone/fetch GITPYTHON_AVAILABLE = True logger = logging.getLogger(__name__) # Get logger after potential import logger.info("GitPython library loaded successfully.") except ImportError: # GitPython is not installed GITPYTHON_AVAILABLE = False logger = logging.getLogger(__name__) # Get logger even if import fails logger.warning( "GitPython library not found. Git-related features will be disabled. " "Install it using: pip install GitPython" ) # Define dummy classes and exceptions to prevent NameErrors later # if code paths relying on these types are reached conditionally. class GitCommandError(Exception): pass class InvalidGitRepositoryError(Exception): pass class NoSuchPathError(Exception): pass class RemoteProgress: pass # Dummy progress reporter base class # Dummy Git object placeholders (optional, but can help type hints) class Repo: pass class Remote: pass class Commit: pass class Head: pass class Reference: pass class TagReference: pass # Add other specific types if needed # --- Internal Model Import --- # Import the registry model needed by GitManager functions try: # Use relative import as registry_models is in the same 'core' package from .registry_models import ToolRegistryEntry except ImportError: # This should ideally not happen if the package structure is correct logger.critical( "Failed to import .registry_models. GitManager functions may fail!", exc_info=True ) # Define a dummy dataclass as a fallback from dataclasses import dataclass @dataclass class ToolRegistryEntry: id: str = "dummy_import_failed" type: str = "unknown" git_url: Optional[str] = None git_ref: str = "main" local_dir_name: Optional[str] = None # --- Constants --- # Git Status Strings (used for reporting status consistently) GIT_STATUS_UP_TO_DATE = "Up-to-date" GIT_STATUS_BEHIND = "Behind" GIT_STATUS_AHEAD = "Ahead" GIT_STATUS_DIVERGED = "Diverged" GIT_STATUS_NOT_CLONED = "Not Cloned" GIT_STATUS_ERROR = "Error" # Generic Git or manager error GIT_STATUS_CHECKING = "Checking..." # UI transient state GIT_STATUS_UPDATING = "Updating..." # UI transient state GIT_STATUS_GITPYTHON_MISSING = "GitPython Missing" # Specific error if library unavailable # Calculate base paths try: # Path to this file: .../ProjectUtility/projectutility/core/git_manager.py _current_file_path = os.path.abspath(__file__) # Path to the 'core' directory: .../ProjectUtility/projectutility/core _core_dir = os.path.dirname(_current_file_path) # Path to the 'projectutility' package source root: .../ProjectUtility/projectutility _app_source_root = os.path.dirname(_core_dir) # Path to the REPOSITORY ROOT: .../ProjectUtility/ APP_ROOT_DIR = os.path.dirname(_app_source_root) # Define managed tools directory relative to the REPOSITORY ROOT MANAGED_TOOLS_DIR = os.path.join(APP_ROOT_DIR, "managed_tools") logger.debug(f"Git Manager: Calculated APP_ROOT_DIR (Repo Root): {APP_ROOT_DIR}") logger.debug(f"Git Manager: Managed tools directory (MANAGED_TOOLS_DIR): {MANAGED_TOOLS_DIR}") except Exception as e: logger.critical( f"Failed to calculate base paths in git_manager: {e}. " f"Managed tools path will be incorrect.", exc_info=True ) # Fallback path - operations will likely fail if relative paths are expected APP_ROOT_DIR = os.getcwd() MANAGED_TOOLS_DIR = os.path.join(APP_ROOT_DIR, "managed_tools") logger.warning(f"Using fallback managed tools path: {MANAGED_TOOLS_DIR}") # --- Progress Reporting Class --- class CloneProgressReporter(RemoteProgress): """ Callback handler for reporting git clone/fetch progress updates. This class is passed to GitPython's clone_from or fetch methods to receive progress information during network operations. """ def __init__( self, tool_id: str, progress_callback: Optional[Callable[[str, str], None]] = None, ): """ Initializes the progress reporter. Args: tool_id: The ID of the tool being operated on (for context). progress_callback: An optional function to call with updates. It should accept two string arguments: message_type ("status" or "progress") and message_content. """ super().__init__() # Initialize the base class self.tool_id = tool_id self.progress_callback = progress_callback self._last_message = "" # Store last message to avoid duplicates if needed def update(self, op_code, cur_count, max_count=None, message=""): """ Called by GitPython with progress updates. Args: op_code: A code indicating the stage of the operation (bitwise flags). cur_count: Current count/progress value. max_count: Maximum count/progress value (can be None). message: A descriptive message about the current operation stage. """ # op_code flags can be checked using bitwise AND (&) # Example: if op_code & self.COMPRESSING: ... # See git.RemoteProgress documentation for details on op_code flags. # We primarily rely on the message provided by GitPython if self.progress_callback and message: # Format a user-friendly progress message progress_text = f"[{self.tool_id}] Git: {message.strip()}" # Optional: Avoid sending the exact same message repeatedly # if progress_text == self._last_message: # return # self._last_message = progress_text try: # Send the message using the provided callback function # Use "progress" type for detailed messages during operation self.progress_callback("git_progress", progress_text) except Exception as e: # Avoid crashing the git operation due to a bad callback logger.error( f"Error occurred within the progress callback " f"function for tool '{self.tool_id}': {e}", exc_info=True # Log traceback for callback errors ) # --- Helper Functions --- def get_local_repo_path(tool_id: str, local_dir_name: Optional[str] = None) -> str: """ Determines the target absolute local directory path for a tool's repository. Args: tool_id: The unique ID of the tool. local_dir_name: Optional custom directory name from the registry. If None, the tool_id is used as the directory name. Returns: The absolute path to the expected local repository directory within MANAGED_TOOLS_DIR. """ # Use the provided local_dir_name or default to the tool_id dir_name = local_dir_name if local_dir_name else tool_id # Ensure the path is absolute, joining with the calculated MANAGED_TOOLS_DIR absolute_path = os.path.abspath(os.path.join(MANAGED_TOOLS_DIR, dir_name)) logger.debug(f"Resolved local repo path for '{tool_id}': {absolute_path}") return absolute_path def _get_repo_object(local_path: str) -> Optional["Repo"]: """ Safely gets the GitPython Repo object for a given local path. Handles cases where GitPython is unavailable, the path doesn't exist, or it's not a valid Git repository. Args: local_path: The absolute path to check for a Git repository. Returns: A git.Repo object if a valid repository is found, otherwise None. """ if not GITPYTHON_AVAILABLE: logger.error("Cannot get Repo object: GitPython library is not available.") return None if not os.path.isdir(local_path): # If the directory doesn't even exist, it can't be a repo. logger.debug(f"Directory does not exist, cannot be a repo: {local_path}") return None try: # Attempt to instantiate the Repo object repo = git.Repo(local_path) logger.debug(f"Successfully accessed Git repository object at: {local_path}") return repo except InvalidGitRepositoryError: # The directory exists but doesn't contain a valid .git directory logger.warning( f"Directory exists but is not a valid Git repository: {local_path}" ) return None except NoSuchPathError: # This might happen in race conditions or unusual filesystem issues logger.error(f"GitPython reported 'NoSuchPathError' for existing (?) directory: {local_path}") return None except Exception as e: # Catch any other unexpected errors during Repo instantiation logger.exception(f"Unexpected error accessing repository at {local_path}: {e}") return None def _fetch_origin( repo: "Repo", tool_id: str, progress_callback: Optional[Callable[[str, str], None]] = None, ) -> bool: """ Performs 'git fetch --prune origin' on the given repository object. Args: repo: The git.Repo object to perform the fetch on. tool_id: The ID of the tool (for logging/progress context). progress_callback: Optional callback function for progress reporting. Returns: True if the fetch operation was successful, False otherwise. """ if not GITPYTHON_AVAILABLE: logger.error(f"[{tool_id}] Cannot fetch: GitPython is not available.") return False logger.info(f"[{tool_id}] Attempting to fetch updates from 'origin'...") if progress_callback: # Send a status update before starting the potentially long operation try: progress_callback("git_status", f"[{tool_id}] Fetching from remote...") except Exception as cb_err: logger.error(f"Error in progress callback (pre-fetch): {cb_err}", exc_info=True) try: # Get the 'origin' remote if not repo.remotes: logger.error(f"[{tool_id}] Cannot fetch: Repository has no remotes configured.") return False if 'origin' not in [r.name for r in repo.remotes]: logger.error(f"[{tool_id}] Cannot fetch: Remote named 'origin' not found.") # You might want to try fetching from the first available remote as a fallback? # remote_to_fetch = repo.remotes[0] return False origin = repo.remotes.origin # Create progress reporter instance if a callback is provided progress_reporter = ( CloneProgressReporter(tool_id, progress_callback) if progress_callback else None ) # Execute the fetch command with pruning and progress reporting # prune=True removes remote-tracking branches that no longer exist on the remote fetch_info_list = origin.fetch(prune=True, progress=progress_reporter) # Log fetch results (fetch_info_list contains details about fetched refs) # This can be verbose, maybe log only if DEBUG is enabled if logger.isEnabledFor(logging.DEBUG): for info in fetch_info_list: logger.debug(f"[{tool_id}] Fetch info: {info.name}, {info.flags}, {info.note}") logger.info(f"[{tool_id}] Fetch operation completed successfully.") if progress_callback: try: progress_callback("git_status", f"[{tool_id}] Fetch complete.") except Exception as cb_err: logger.error(f"Error in progress callback (post-fetch): {cb_err}", exc_info=True) return True except GitCommandError as e: # Handle errors during the git fetch command execution error_output = e.stderr.strip() if e.stderr else str(e) logger.error( f"[{tool_id}] Git fetch command failed. Error: {error_output}", exc_info=True # Log traceback for GitCommandError ) if progress_callback: try: progress_callback("git_error", f"[{tool_id}] Fetch failed: {error_output}") except Exception as cb_err: logger.error(f"Error in progress callback (fetch error): {cb_err}", exc_info=True) return False except AttributeError: # Should be caught by the check above, but handle defensively logger.error(f"[{tool_id}] Failed to access remote 'origin'.") return False except Exception as e: # Catch any other unexpected errors during fetch logger.exception(f"[{tool_id}] An unexpected error occurred during fetch: {e}") if progress_callback: try: progress_callback("git_error", f"[{tool_id}] Unexpected fetch error: {e}") except Exception as cb_err: logger.error(f"Error in progress callback (fetch unexpected error): {cb_err}", exc_info=True) return False # --- Core Git Operations --- def ensure_repository_cloned( tool_entry: ToolRegistryEntry, progress_callback: Optional[Callable[[str, str], None]] = None, ) -> Tuple[Optional["Repo"], str]: """ Ensures the repository for the given tool entry exists locally. Checks if the repository directory exists and is valid. If not, attempts to clone it from the URL specified in the tool entry. Uses the progress callback during the clone operation. Args: tool_entry: The ToolRegistryEntry object for the tool. progress_callback: Optional function to report progress messages. Receives type ("git_status", "git_progress", "git_error") and message string. Returns: A tuple containing: - The git.Repo object if the repository exists or was cloned successfully. - None if GitPython is unavailable, the tool is not Git type, or cloning failed. - A status message string indicating the outcome. """ if not GITPYTHON_AVAILABLE: logger.error("Cannot ensure clone: GitPython library not installed.") return None, GIT_STATUS_GITPYTHON_MISSING if tool_entry.type != "git" or not tool_entry.git_url: logger.warning(f"Tool '{tool_entry.id}' is not configured for Git or lacks URL. Cannot clone.") return None, "Tool not configured for Git or missing URL." tool_id = tool_entry.id local_path = get_local_repo_path(tool_id, tool_entry.local_dir_name) # Check if a valid repository already exists at the target path repo = _get_repo_object(local_path) if repo: logger.debug(f"[{tool_id}] Repository already exists at: {local_path}") return repo, "Repository exists locally." # --- Repository does not exist or is invalid, attempt to clone --- logger.info(f"[{tool_id}] Repository not found locally. Attempting to clone from " f"'{tool_entry.git_url}' into '{local_path}'...") if progress_callback: try: progress_callback("git_status", f"[{tool_id}] Cloning {tool_entry.display_name}...") except Exception as cb_err: logger.error(f"Error in progress callback (pre-clone): {cb_err}", exc_info=True) # Ensure the parent directory ('managed_tools') exists try: os.makedirs(MANAGED_TOOLS_DIR, exist_ok=True) except OSError as e: logger.error( f"[{tool_id}] Failed to create parent directory for managed tools " f"'{MANAGED_TOOLS_DIR}': {e}. Cannot clone.", exc_info=True ) return None, f"Failed to create base directory: {e}" # Create progress reporter instance progress_reporter = ( CloneProgressReporter(tool_id, progress_callback) if progress_callback else None ) cloned_repo: Optional["Repo"] = None try: # Execute the clone operation cloned_repo = git.Repo.clone_from( url=tool_entry.git_url, to_path=local_path, progress=progress_reporter, # You can specify the initial branch/ref to clone here using 'branch=' # branch=tool_entry.git_ref # Clone specific ref initially ) logger.info(f"[{tool_id}] Repository cloned successfully to {local_path}.") if progress_callback: try: progress_callback("git_status", f"[{tool_id}] Clone successful.") except Exception as cb_err: logger.error(f"Error in progress callback (post-clone success): {cb_err}", exc_info=True) return cloned_repo, "Repository cloned successfully." except GitCommandError as e: # Handle errors during the git clone command error_output = e.stderr.strip() if e.stderr else str(e) logger.error( f"[{tool_id}] Failed to clone repository from '{tool_entry.git_url}'. " f"Error: {error_output}", exc_info=True ) clone_failed_msg = f"Failed to clone: {error_output}" if progress_callback: try: progress_callback("git_error", f"[{tool_id}] {clone_failed_msg}") except Exception as cb_err: logger.error(f"Error in progress callback (clone GitCommandError): {cb_err}", exc_info=True) except Exception as e: # Handle other unexpected errors during clone logger.exception(f"[{tool_id}] An unexpected error occurred during clone: {e}") clone_failed_msg = f"Unexpected clone error: {e}" if progress_callback: try: progress_callback("git_error", f"[{tool_id}] {clone_failed_msg}") except Exception as cb_err: logger.error(f"Error in progress callback (clone unexpected error): {cb_err}", exc_info=True) # --- Cleanup after failed clone attempt --- # If cloning failed, remove the potentially incomplete directory if os.path.exists(local_path): logger.warning(f"[{tool_id}] Attempting to clean up incomplete clone directory: {local_path}") try: shutil.rmtree(local_path) logger.info(f"[{tool_id}] Successfully removed incomplete clone directory.") except OSError as rm_e: logger.error( f"[{tool_id}] Failed to remove incomplete clone directory " f"'{local_path}' after clone failure: {rm_e}", exc_info=True ) except Exception as rm_e: logger.exception(f"[{tool_id}] Unexpected error removing directory '{local_path}': {rm_e}") return None, clone_failed_msg # Return None repo and the error message def get_repository_status(tool_entry: ToolRegistryEntry) -> Dict[str, Any]: """ Checks the status of the local repository against the configured remote ref. Performs a 'git fetch' before comparing the local HEAD commit with the commit of the remote-tracking branch corresponding to the tool's 'git_ref'. Args: tool_entry: The ToolRegistryEntry for the Git tool. Returns: A dictionary containing status details: - 'status': String indicating the status (e.g., GIT_STATUS_UP_TO_DATE). - 'local_hash': The SHA hash of the local HEAD commit (or None). - 'remote_hash': The SHA hash of the remote tracking ref commit (or None). - 'local_ref': Name of the local branch/tag or short hash if detached (or None). - 'remote_ref': Full name of the remote tracking ref (e.g., 'origin/main') (or None). - 'message': A user-friendly message describing the status or error. """ # Initialize the result dictionary with default error state status_result: Dict[str, Any] = { "status": GIT_STATUS_ERROR, "local_hash": None, "remote_hash": None, "local_ref": None, "remote_ref": None, "message": "Initialization error during status check.", } if not GITPYTHON_AVAILABLE: status_result.update( {"status": GIT_STATUS_GITPYTHON_MISSING, "message": "GitPython library not installed."} ) return status_result if tool_entry.type != "git" or not tool_entry.git_url: status_result.update({"status": GIT_STATUS_ERROR, "message": "Tool is not configured for Git or lacks URL."}) return status_result tool_id = tool_entry.id local_path = get_local_repo_path(tool_id, tool_entry.local_dir_name) # Get the repository object repo = _get_repo_object(local_path) if not repo: # Repository doesn't exist locally status_result.update( {"status": GIT_STATUS_NOT_CLONED, "message": "Repository not found locally. Needs cloning."} ) return status_result logger.info(f"[{tool_id}] Checking repository status at {local_path}...") # --- Perform Fetch Before Checking Status --- # Fetch updates from 'origin' to get the latest remote state. # Pass no progress callback for simple status check fetch. if not _fetch_origin(repo, tool_id, progress_callback=None): # If fetch fails, we cannot reliably determine the status against remote. status_result.update( {"status": GIT_STATUS_ERROR, "message": "Fetch from remote failed. Cannot determine status."} ) # Optionally, still try to get local info? try: status_result["local_hash"] = repo.head.commit.hexsha status_result["local_ref"] = _get_local_ref_name(repo) except Exception: pass # Ignore errors if we can't even get local info return status_result # --- Get Local and Remote Commit Information --- try: # Get local commit (HEAD) local_commit: "Commit" = repo.head.commit status_result["local_hash"] = local_commit.hexsha status_result["local_ref"] = _get_local_ref_name(repo) # Use helper # Construct the name of the remote tracking reference # Assumes the remote is named 'origin' remote_ref_name = f"origin/{tool_entry.git_ref}" status_result["remote_ref"] = remote_ref_name # Get the commit object for the remote tracking reference remote_commit: Optional["Commit"] = None try: remote_commit = repo.commit(remote_ref_name) status_result["remote_hash"] = remote_commit.hexsha except (git.BadName, GitCommandError) as e: # Handle case where the remote ref doesn't exist (e.g., typo in git_ref) logger.warning( f"[{tool_id}] Could not find remote reference '{remote_ref_name}' " f"after fetch. Error: {e}" ) status_result.update( { "status": GIT_STATUS_ERROR, "remote_hash": None, # Ensure remote hash is None "message": f"Remote reference '{remote_ref_name}' not found on origin.", } ) return status_result # --- Compare local and remote commits --- if local_commit == remote_commit: status_result.update({"status": GIT_STATUS_UP_TO_DATE, "message": "Local repository is up-to-date."}) elif repo.is_ancestor(local_commit.hexsha, remote_commit.hexsha): # Local commit is an ancestor of the remote commit -> Behind status_result.update({"status": GIT_STATUS_BEHIND, "message": "Local repository is behind the remote reference. Update available."}) elif repo.is_ancestor(remote_commit.hexsha, local_commit.hexsha): # Remote commit is an ancestor of the local commit -> Ahead status_result.update({"status": GIT_STATUS_AHEAD, "message": "Local repository is ahead of the remote reference (local changes exist)."}) else: # Commits have diverged (neither is an ancestor of the other) status_result.update( {"status": GIT_STATUS_DIVERGED, "message": "Local repository has diverged from the remote reference."} ) logger.info(f"[{tool_id}] Status check complete: {status_result['status']}") except GitCommandError as e: # Handle Git errors during status checking commands (e.g., commit lookup) error_output = e.stderr.strip() if e.stderr else str(e) logger.error( f"[{tool_id}] Git command error during status check: {error_output}", exc_info=True ) status_result.update({"status": GIT_STATUS_ERROR, "message": f"Git command error during status check: {error_output}"}) except Exception as e: # Catch any other unexpected errors logger.exception(f"[{tool_id}] Unexpected error during repository status check: {e}") status_result.update({"status": GIT_STATUS_ERROR, "message": f"Unexpected error during status check: {e}"}) return status_result def _get_local_ref_name(repo: "Repo") -> str: """Helper to get the name of the current local reference (branch/tag/detached).""" if not GITPYTHON_AVAILABLE: return "N/A" try: head = repo.head if head.is_detached: # For detached HEAD, show the short hash return f"Detached@{head.commit.hexsha[:7]}" else: # For branch or tag, show the reference name return head.ref.name except Exception as e: logger.warning(f"Could not determine local reference name: {e}") return "Unknown" def update_repository( tool_entry: ToolRegistryEntry, progress_callback: Optional[Callable[[str, str], None]] = None, ) -> Tuple[bool, str]: """ Updates the local repository to match the configured remote ref. Performs the following steps: 1. Ensures the repository is cloned. 2. Fetches updates from the 'origin' remote. 3. Checks out the specific 'git_ref' specified in the tool entry. This might result in a detached HEAD state if 'git_ref' is a tag or commit hash. 4. If the checkout results in being on a branch (not detached HEAD), it attempts a fast-forward pull (--ff-only) to ensure the local branch matches the remote tracking branch. Args: tool_entry: The ToolRegistryEntry object for the Git tool. progress_callback: Optional function to report progress messages. Returns: A tuple containing: - success_boolean: True if the update process completed successfully (repo is now at the target ref), False otherwise. - status_message_string: A message indicating the outcome of the update. """ if not GITPYTHON_AVAILABLE: return False, GIT_STATUS_GITPYTHON_MISSING if tool_entry.type != "git" or not tool_entry.git_url: return False, "Tool is not configured for Git or lacks URL." tool_id = tool_entry.id logger.info(f"[{tool_id}] Starting repository update process...") # --- Step 1: Ensure Repository is Cloned --- repo, clone_msg = ensure_repository_cloned(tool_entry, progress_callback) if not repo: # If cloning failed or repo doesn't exist after check logger.error(f"[{tool_id}] Update failed: Could not ensure repository exists. Reason: {clone_msg}") return False, f"Update failed: {clone_msg}" # --- Step 2: Fetch Latest Changes --- if not _fetch_origin(repo, tool_id, progress_callback): # If fetch fails, cannot proceed with checkout/pull reliably logger.error(f"[{tool_id}] Update failed: Fetch from remote 'origin' failed.") return False, "Update failed: Could not fetch updates from remote." # --- Step 3: Checkout the Target Reference --- target_ref = tool_entry.git_ref # The branch, tag, or commit hash to checkout remote_target_ref = f"origin/{target_ref}" # Assume origin for remote ref check logger.info(f"[{tool_id}] Attempting to checkout target reference: '{target_ref}'") if progress_callback: try: progress_callback("git_status", f"[{tool_id}] Checking out '{target_ref}'...") except Exception as cb_err: logger.error(f"Error in progress callback (pre-checkout): {cb_err}", exc_info=True) try: # Check if the target remote ref exists before attempting checkout try: target_commit_on_remote = repo.commit(remote_target_ref) logger.debug(f"[{tool_id}] Verified remote reference '{remote_target_ref}' exists (Commit: {target_commit_on_remote.hexsha[:7]})") except (git.BadName, GitCommandError): logger.error(f"[{tool_id}] Update failed: Target reference '{target_ref}' (checked as '{remote_target_ref}') does not exist on the remote 'origin'.") return False, f"Update failed: Reference '{target_ref}' not found on remote 'origin'." # Check if already at the target commit to potentially avoid work current_commit_sha = repo.head.commit.hexsha target_commit_sha = target_commit_on_remote.hexsha if current_commit_sha == target_commit_sha: logger.info( f"[{tool_id}] Already at the target commit {target_commit_sha[:7]} for reference '{target_ref}'." ) # Even if at the commit, ensure we are on the correct branch/tag if possible, # or proceed to pull check if on a branch. # Attempt checkout anyway to handle cases like being detached at the right commit # or being on the wrong branch that happens to point to the same commit. repo.git.checkout(target_ref) # Use raw git command via GitPython for checkout else: # Perform the checkout # Using repo.git.checkout() is often more robust than repo.heads['...'].checkout() # It handles branches, tags, and commit hashes directly. # Consider adding --force? Use with caution, as it discards local changes. # repo.git.checkout(target_ref, force=True) repo.git.checkout(target_ref) logger.info(f"[{tool_id}] Successfully checked out '{target_ref}'.") if progress_callback: try: progress_callback("git_status", f"[{tool_id}] Checkout complete.") except Exception as cb_err: logger.error(f"Error in progress callback (post-checkout): {cb_err}", exc_info=True) # --- Step 4: Pull if on a Branch (Fast-Forward Only) --- if not repo.head.is_detached: # We are on a local branch after checkout current_branch_name = repo.head.ref.name logger.info( f"[{tool_id}] Currently on local branch '{current_branch_name}'. " f"Attempting fast-forward pull from 'origin/{current_branch_name}'..." ) if progress_callback: try: progress_callback("git_status", f"[{tool_id}] Pulling branch '{current_branch_name}' (ff-only)...") except Exception as cb_err: logger.error(f"Error in progress callback (pre-pull): {cb_err}", exc_info=True) try: # Use 'git pull --ff-only origin ' # This ensures we only update if it's a simple fast-forward, # preventing merges or failures if local branch has diverged. repo.git.pull("origin", current_branch_name, "--ff-only") logger.info(f"[{tool_id}] Fast-forward pull successful for branch '{current_branch_name}'.") if progress_callback: try: progress_callback("git_status", f"[{tool_id}] Pull successful.") except Exception as cb_err: logger.error(f"Error in progress callback (post-pull success): {cb_err}", exc_info=True) # Final success message after checkout and pull return True, f"Repository updated successfully. Now on branch '{current_branch_name}' tracking '{target_ref}'." except GitCommandError as pull_err: # Handle cases where ff-only pull fails error_output = pull_err.stderr.strip() if pull_err.stderr else str(pull_err) logger.warning( f"[{tool_id}] Fast-forward pull failed for branch '{current_branch_name}'. " f"Git output: {error_output}" ) # Check the status *after* the failed pull. Maybe checkout already put us in the desired state? status_now = get_repository_status(tool_entry) if status_now["status"] == GIT_STATUS_UP_TO_DATE: logger.info( f"[{tool_id}] Although ff-only pull failed, the repository is now " f"up-to-date with '{target_ref}'. Considering update successful." ) if progress_callback: try: progress_callback("git_status", f"[{tool_id}] Pull failed (ff-only), but repo is Up-to-date.") except Exception as cb_err: logger.error(f"Error in progress callback (pull fail but ok): {cb_err}", exc_info=True) return True, f"Repository is up-to-date at '{target_ref}' (ff-pull failed/unnecessary)." else: # If ff-pull failed AND repo is not up-to-date, report error logger.error( f"[{tool_id}] Update failed: Fast-forward pull failed for branch " f"'{current_branch_name}' and repository is not up-to-date " f"(Status: {status_now['status']}). Manual intervention might be needed. " f"Error: {error_output}" ) return False, f"Update failed: Pull error on branch '{current_branch_name}' ({status_now['status']}). Check logs." else: # Detached HEAD state after checkout # Checkout was successful, but resulted in a detached HEAD (e.g., checked out a tag or commit hash) detached_commit_sha = repo.head.commit.hexsha[:7] logger.info( f"[{tool_id}] Checkout resulted in a detached HEAD state at commit " f"{detached_commit_sha} (target ref '{target_ref}'). Update considered complete." ) return True, f"Repository checked out successfully at '{target_ref}' (Detached HEAD)." except GitCommandError as e: # Handle errors during checkout or other git operations error_output = e.stderr.strip() if e.stderr else str(e) logger.error( f"[{tool_id}] Failed git operation while trying to update to ref '{target_ref}'. " f"Error code: {e.status}. Output: {error_output}", exc_info=True, ) return False, f"Update failed: Git error during checkout/update (Code: {e.status}). Check logs." except Exception as e: # Catch any other unexpected errors during the update process logger.exception(f"[{tool_id}] An unexpected error occurred during repository update: {e}") return False, f"Update failed: Unexpected error - {e}"