SXXXXXXX_ProjectUtility/projectutility/core/git_manager.py
2025-05-05 14:38:19 +02:00

785 lines
35 KiB
Python

# 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 <branch>'
# 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}"