SXXXXXXX_GitUtility/gitutility/core/wiki_updater.py
2025-12-01 14:21:13 +01:00

284 lines
12 KiB
Python

# --- FILE: gitsync_tool/core/wiki_updater.py ---
import os
import shutil
import tempfile
import re
import time
from typing import Optional
from urllib.parse import urlparse, urlunparse
from enum import Enum, auto
from dataclasses import dataclass
from ..commands.git_commands import GitCommands, GitCommandError
from ..logging_setup import log_handler
class WikiUpdateStatus(Enum):
"""Represents the result of the wiki update operation."""
SUCCESS = auto()
NO_CHANGES = auto()
URL_DERIVATION_FAILED = auto()
CLONE_FAILED = auto()
PUSH_FAILED = auto()
COMMIT_FAILED = auto()
DOC_NOT_FOUND = auto()
BRANCH_NOT_FOUND = auto()
GENERIC_ERROR = auto()
@dataclass
class WikiUpdateResult:
"""Holds the result of the wiki update operation."""
status: WikiUpdateStatus
message: str
class WikiUpdater:
DEFAULT_WIKI_EN_FILENAME = "English-Manual.md"
DEFAULT_WIKI_IT_FILENAME = "Italian-Manual.md"
def __init__(self, git_commands: GitCommands):
if not isinstance(git_commands, GitCommands):
raise TypeError("WikiUpdater requires a GitCommands instance.")
self.git_commands: GitCommands = git_commands
log_handler.log_debug("WikiUpdater initialized.", func_name="__init__")
def _get_wiki_repo_url(self, main_repo_url: str) -> Optional[str]:
if not main_repo_url:
return None
try:
parsed_url = urlparse(main_repo_url)
path = parsed_url.path
if path.lower().endswith(".git"):
new_path = path[:-4] + ".wiki.git"
else:
new_path = path.rstrip("/") + ".wiki.git"
wiki_url_parts = parsed_url._replace(path=new_path)
return urlunparse(wiki_url_parts)
except Exception as e:
log_handler.log_error(
f"Could not derive Wiki URL from '{main_repo_url}': {e}",
func_name="_get_wiki_repo_url",
)
return None
def update_wiki_from_docs(
self,
main_repo_path: str,
main_repo_remote_url: str,
doc_dir_name: str = "doc",
en_manual_filename: str = "English-Manual.md",
it_manual_filename: str = "Italian-Manual.md",
wiki_en_target_filename: Optional[str] = None,
wiki_it_target_filename: Optional[str] = None,
commit_message: str = "Update Wiki documentation from local files",
) -> WikiUpdateResult:
"""
Clones the Gitea wiki repo, updates pages from local doc files, and pushes.
"""
func_name = "update_wiki_from_docs"
log_handler.log_debug("Starting wiki update process.", func_name=func_name)
wiki_url = self._get_wiki_repo_url(main_repo_remote_url)
if not wiki_url:
msg = "Could not derive Wiki repository URL from main remote URL."
log_handler.log_error(msg, func_name=func_name)
return WikiUpdateResult(
status=WikiUpdateStatus.URL_DERIVATION_FAILED, message=msg
)
log_handler.log_debug(f"Derived wiki URL: {wiki_url}", func_name=func_name)
doc_path = os.path.join(main_repo_path, doc_dir_name)
en_doc_file = os.path.join(doc_path, en_manual_filename)
it_doc_file = os.path.join(doc_path, it_manual_filename)
target_en_wiki_file = wiki_en_target_filename or self.DEFAULT_WIKI_EN_FILENAME
target_it_wiki_file = wiki_it_target_filename or self.DEFAULT_WIKI_IT_FILENAME
en_exists = os.path.isfile(en_doc_file)
it_exists = os.path.isfile(it_doc_file)
if not en_exists and not it_exists:
msg = f"Neither '{en_manual_filename}' nor '{it_manual_filename}' found in '{doc_path}'. Cannot update Wiki."
log_handler.log_warning(msg, func_name=func_name)
return WikiUpdateResult(status=WikiUpdateStatus.DOC_NOT_FOUND, message=msg)
temp_dir: Optional[str] = None
try:
temp_dir = tempfile.mkdtemp(prefix="gitea_wiki_update_")
log_handler.log_info(
f"Cloning Wiki repo into temp dir: {temp_dir}", func_name=func_name
)
clone_result = self.git_commands.git_clone(wiki_url, temp_dir)
if clone_result.returncode != 0:
stderr_msg = clone_result.stderr or "Unknown clone error"
if (
"repository not found" in stderr_msg.lower()
or "does not appear to be a git repository" in stderr_msg.lower()
):
msg = f"Failed to clone Wiki: Repository at '{wiki_url}' not found. Please create the first page manually."
else:
msg = f"Failed to clone Wiki repository '{wiki_url}'. Error: {stderr_msg.strip()}"
log_handler.log_error(msg, func_name=func_name)
return WikiUpdateResult(
status=WikiUpdateStatus.CLONE_FAILED,
message=f"{msg} (Check authentication?)",
)
log_handler.log_info(
"Wiki repository cloned successfully.", func_name=func_name
)
if en_exists:
with open(en_doc_file, "r", encoding="utf-8") as f_en:
en_content = f_en.read()
with open(
os.path.join(temp_dir, target_en_wiki_file),
"w",
encoding="utf-8",
newline="\n",
) as f_wiki_en:
f_wiki_en.write(en_content)
if it_exists:
with open(it_doc_file, "r", encoding="utf-8") as f_it:
it_content = f_it.read()
with open(
os.path.join(temp_dir, target_it_wiki_file),
"w",
encoding="utf-8",
newline="\n",
) as f_wiki_it:
f_wiki_it.write(it_content)
if not self.git_commands.git_status_has_changes(temp_dir):
msg = "Wiki content is already up-to-date with local doc files."
log_handler.log_info(msg, func_name=func_name)
return WikiUpdateResult(status=WikiUpdateStatus.NO_CHANGES, message=msg)
log_handler.log_info(
"Changes detected. Staging with --renormalize to handle line endings.",
func_name=func_name,
)
self.git_commands.add_file(temp_dir, ".", renormalize=True)
# Sometimes local/global exclude rules may prevent files from being
# staged by a normal `git add`. If after the add there are still no
# changes detected, attempt a forced add to ensure files get tracked.
try:
if not self.git_commands.git_status_has_changes(temp_dir):
log_handler.log_warning(
"No changes detected after staging; attempting forced add (-f).",
func_name=func_name,
)
try:
# Use log_and_execute to run a forced add with renormalize
self.git_commands.log_and_execute(
["git", "add", "-f", "--renormalize", "--", "."],
temp_dir,
check=True,
)
except Exception as force_add_e:
log_handler.log_warning(
f"Forced add failed: {force_add_e}", func_name=func_name
)
except Exception:
# If status check itself fails, continue and let commit attempt handle it
pass
# Ensure files are staged before committing. Some environments
# (notably Windows with temp dirs) can leave files untracked
# even after an explicit add call due to timing/locking; calling
# git_commit with `stage_all_first=True` makes this operation
# more robust by running a final `git add .` inside the repo
# before the commit.
commit_success = self.git_commands.git_commit(
temp_dir, commit_message, stage_all_first=True
)
if not commit_success:
msg = "Staged changes, but commit reported no changes to commit. Push will be skipped."
log_handler.log_warning(msg, func_name=func_name)
return WikiUpdateResult(status=WikiUpdateStatus.NO_CHANGES, message=msg)
log_handler.log_info("Wiki changes committed locally.", func_name=func_name)
current_wiki_branch = self.git_commands.get_current_branch_name(temp_dir)
if not current_wiki_branch:
msg = "Could not determine the current branch in the cloned wiki repository."
log_handler.log_error(msg, func_name=func_name)
return WikiUpdateResult(
status=WikiUpdateStatus.BRANCH_NOT_FOUND, message=msg
)
log_handler.log_info(
"Pushing wiki changes to remote...", func_name=func_name
)
push_result = self.git_commands.git_push(
working_directory=temp_dir,
remote_name="origin",
branch_name=current_wiki_branch,
force=False,
)
if push_result.returncode == 0:
msg = "Wiki update pushed successfully to Gitea."
log_handler.log_info(msg, func_name=func_name)
return WikiUpdateResult(status=WikiUpdateStatus.SUCCESS, message=msg)
else:
stderr_msg = push_result.stderr or "Unknown push error"
msg = f"Failed to push Wiki updates. Error: {stderr_msg.strip()}"
log_handler.log_error(msg, func_name=func_name)
return WikiUpdateResult(
status=WikiUpdateStatus.PUSH_FAILED, message=msg
)
except (GitCommandError, ValueError, IOError, Exception) as e:
log_handler.log_exception(
f"Error during wiki update process: {e}", func_name=func_name
)
return WikiUpdateResult(
status=WikiUpdateStatus.GENERIC_ERROR,
message=f"Wiki update failed: {e}",
)
finally:
if temp_dir and os.path.isdir(temp_dir):
log_handler.log_debug(
f"Attempting to clean up temporary directory: {temp_dir}",
func_name=func_name,
)
def _on_rm_error(func, path, exc_info):
# Attempt to fix permissions and retry file removal (Windows)
try:
os.chmod(path, 0o666)
except Exception:
pass
try:
func(path)
except Exception:
raise
for attempt in range(3):
try:
shutil.rmtree(temp_dir, onerror=_on_rm_error)
log_handler.log_info(
f"Cleanup of temporary directory successful.",
func_name=func_name,
)
break
except Exception as clean_e:
log_handler.log_warning(
f"Cleanup attempt {attempt + 1} failed for '{temp_dir}': {clean_e}",
func_name=func_name,
)
if attempt < 2:
time.sleep(0.5)
else:
log_handler.log_error(
f"Final cleanup attempt failed for '{temp_dir}'.",
func_name=func_name,
)