#!/usr/bin/env python3 """PPT Master - Repository Updater Pull the latest Git checkout and sync Python dependencies when the effective requirements include tree changes. Usage: python3 skills/ppt-master/scripts/update_repo.py python3 skills/ppt-master/scripts/update_repo.py --skip-pip Examples: python3 skills/ppt-master/scripts/update_repo.py python3 skills/ppt-master/scripts/update_repo.py --skip-pip Dependencies: None (standard library only) """ from __future__ import annotations import argparse import hashlib import shlex import shutil import subprocess import sys from pathlib import Path from console_encoding import configure_utf8_stdio configure_utf8_stdio() TOOLS_DIR = Path(__file__).resolve().parent SKILL_DIR = TOOLS_DIR.parent REPO_ROOT = SKILL_DIR.parent.parent REQUIREMENTS_FILE = REPO_ROOT / "requirements.txt" def non_git_checkout_message() -> str: return f"""This copy of PPT Master is not a Git checkout, so it cannot be updated automatically. Repository path: {REPO_ROOT} If you installed with Download ZIP: 1. Download the latest ZIP from GitHub or AtomGit. 2. Unzip it into a new folder. 3. Copy your old .env and projects/ folder into the new folder. 4. Run: pip install -r requirements.txt If you want one-command updates next time, install with Git clone: git clone https://github.com/hugohe3/ppt-master.git If you installed through a skill marketplace, update or reinstall through the same marketplace / skills tool.""" def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Pull the latest repository changes and sync Python dependencies " "only when the requirements include tree changes." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--skip-pip", action="store_true", help="Skip Python dependency sync even if the requirements include tree changed.", ) return parser.parse_args(argv) def print_status(message: str = "") -> None: """Print progress/status messages to stderr.""" print(message, file=sys.stderr) def run_command(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: return subprocess.run( args, cwd=REPO_ROOT, check=check, capture_output=True, text=True, encoding="utf-8", errors="replace", ) def _requirement_includes(path: Path, content: str) -> list[Path]: """Resolve local -r/--requirement includes relative to their owning file.""" includes: list[Path] = [] for raw_line in content.splitlines(): try: tokens = shlex.split(raw_line, comments=True, posix=True) except ValueError: continue if not tokens: continue option = tokens[0] include_value: str | None = None if option in {"-r", "--requirement"}: if len(tokens) >= 2: include_value = tokens[1] elif option.startswith("--requirement="): include_value = option.partition("=")[2] elif option.startswith("-r") and len(option) > 2: include_value = option[2:].lstrip("=") if not include_value: continue include_path = Path(include_value) if not include_path.is_absolute(): include_path = path.parent / include_path includes.append(include_path) return includes def requirements_digest(path: Path) -> str | None: """Hash one requirements file and its recursive local include closure.""" if not path.exists(): return None digest = hashlib.sha256() visited: set[Path] = set() def visit(current: Path) -> None: try: resolved = current.resolve() except OSError as exc: raise RuntimeError( f"Unable to resolve requirements file {current}: {exc}" ) from exc if resolved in visited: digest.update(b"repeat\0") return visited.add(resolved) if not resolved.is_file(): digest.update(b"missing\0") return try: content = resolved.read_bytes() except OSError as exc: raise RuntimeError( f"Unable to read requirements file {resolved}: {exc}" ) from exc digest.update(b"file\0") digest.update(len(content).to_bytes(8, "big")) digest.update(content) for included in _requirement_includes( resolved, content.decode("utf-8", errors="replace"), ): visit(included) visit(path) return digest.hexdigest() def ensure_git_available() -> None: if shutil.which("git") is None: raise RuntimeError("Missing executable: git") def ensure_git_checkout() -> None: if not (REPO_ROOT / ".git").exists(): raise RuntimeError(non_git_checkout_message()) def ensure_clean_tracked_worktree() -> None: status = run_command(["git", "status", "--porcelain", "--untracked-files=no"], check=False) if status.returncode != 0: details = (status.stderr or status.stdout or "").strip() raise RuntimeError(details or "Unable to inspect git status.") if status.stdout.strip(): raise RuntimeError( "Tracked local changes detected. Please commit or stash them before running the update command." ) def get_head_revision() -> str: result = run_command(["git", "rev-parse", "HEAD"]) return result.stdout.strip() def sync_python_dependencies() -> None: if not REQUIREMENTS_FILE.exists(): print_status("requirements.txt not found; skipping Python dependency sync.") return print_status("Requirements include tree changed. Syncing Python dependencies...") result = run_command([sys.executable, "-m", "pip", "install", "-r", str(REQUIREMENTS_FILE)]) if result.stdout.strip(): print_status(result.stdout.strip()) if result.stderr.strip(): print_status(result.stderr.strip()) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) try: ensure_git_checkout() ensure_git_available() ensure_clean_tracked_worktree() before_head = get_head_revision() before_requirements = requirements_digest(REQUIREMENTS_FILE) print_status(f"Repository: {REPO_ROOT}") pull_result = run_command(["git", "pull", "--ff-only"]) if pull_result.stdout.strip(): print_status(pull_result.stdout.strip()) if pull_result.stderr.strip(): print_status(pull_result.stderr.strip()) after_head = get_head_revision() after_requirements = requirements_digest(REQUIREMENTS_FILE) if before_head == after_head: print_status("Repository is already up to date.") else: print_status(f"Updated from {before_head[:7]} to {after_head[:7]}.") if args.skip_pip: print_status("Skipped Python dependency sync (--skip-pip).") elif before_requirements != after_requirements: sync_python_dependencies() else: print_status( "Requirements include tree unchanged. Skipping Python dependency sync." ) print_status( "Note: system dependencies such as Node.js and Pandoc still need " "to be installed manually." ) return 0 except subprocess.CalledProcessError as exc: details = (exc.stderr or exc.stdout or "").strip() print(details or "Command failed.", file=sys.stderr) return exc.returncode or 1 except RuntimeError as exc: print(str(exc), file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())