785 lines
35 KiB
Python
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 projectutility.core.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}" |