repo.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. """Repository management module for syncing library repositories."""
  2. from __future__ import annotations
  3. import logging
  4. import subprocess
  5. from pathlib import Path
  6. from typing import Optional
  7. from rich.console import Console
  8. from rich.panel import Panel
  9. from rich.progress import Progress, SpinnerColumn, TextColumn
  10. from rich.table import Table
  11. from typer import Argument, Option, Typer
  12. from ..core.config import ConfigManager
  13. from ..core.display import DisplayManager, IconManager
  14. from ..core.exceptions import ConfigError
  15. logger = logging.getLogger(__name__)
  16. console = Console()
  17. console_err = Console(stderr=True)
  18. display = DisplayManager()
  19. app = Typer(help="Manage library repositories")
  20. def _run_git_command(args: list[str], cwd: Optional[Path] = None) -> tuple[bool, str, str]:
  21. """Run a git command and return the result.
  22. Args:
  23. args: Git command arguments (without 'git' prefix)
  24. cwd: Working directory for the command
  25. Returns:
  26. Tuple of (success, stdout, stderr)
  27. """
  28. try:
  29. result = subprocess.run(
  30. ["git"] + args,
  31. cwd=cwd,
  32. capture_output=True,
  33. text=True,
  34. timeout=300 # 5 minute timeout
  35. )
  36. return result.returncode == 0, result.stdout, result.stderr
  37. except subprocess.TimeoutExpired:
  38. return False, "", "Command timed out after 5 minutes"
  39. except FileNotFoundError:
  40. return False, "", "Git command not found. Please install git."
  41. except Exception as e:
  42. return False, "", str(e)
  43. def _clone_or_pull_repo(name: str, url: str, target_path: Path, branch: Optional[str] = None, sparse_dir: Optional[str] = None) -> tuple[bool, str]:
  44. """Clone or pull a git repository with optional sparse-checkout.
  45. Args:
  46. name: Library name
  47. url: Git repository URL
  48. target_path: Target directory for the repository
  49. branch: Git branch to clone/pull (optional)
  50. sparse_dir: Directory to sparse-checkout (optional, use None or "." for full clone)
  51. Returns:
  52. Tuple of (success, message)
  53. """
  54. if target_path.exists() and (target_path / ".git").exists():
  55. # Repository exists, pull updates
  56. logger.debug(f"Pulling updates for library '{name}' at {target_path}")
  57. # Determine which branch to pull
  58. pull_branch = branch if branch else "main"
  59. # Pull updates from specific branch
  60. success, stdout, stderr = _run_git_command(
  61. ["pull", "--ff-only", "origin", pull_branch],
  62. cwd=target_path
  63. )
  64. if success:
  65. # Check if anything was updated
  66. if "Already up to date" in stdout or "Already up-to-date" in stdout:
  67. return True, "Already up to date"
  68. else:
  69. return True, "Updated successfully"
  70. else:
  71. error_msg = stderr or stdout
  72. logger.error(f"Failed to pull library '{name}': {error_msg}")
  73. return False, f"Pull failed: {error_msg}"
  74. else:
  75. # Repository doesn't exist, clone it
  76. logger.debug(f"Cloning library '{name}' from {url} to {target_path}")
  77. # Ensure parent directory exists
  78. target_path.parent.mkdir(parents=True, exist_ok=True)
  79. # Determine if we should use sparse-checkout
  80. use_sparse = sparse_dir and sparse_dir != "."
  81. if use_sparse:
  82. # Use sparse-checkout to clone only specific directory
  83. logger.debug(f"Using sparse-checkout for directory: {sparse_dir}")
  84. # Initialize empty repo
  85. success, stdout, stderr = _run_git_command(["init"], cwd=None)
  86. if success:
  87. # Create target directory
  88. target_path.mkdir(parents=True, exist_ok=True)
  89. # Initialize git repo
  90. success, stdout, stderr = _run_git_command(["init"], cwd=target_path)
  91. if not success:
  92. return False, f"Failed to initialize repo: {stderr or stdout}"
  93. # Add remote
  94. success, stdout, stderr = _run_git_command(["remote", "add", "origin", url], cwd=target_path)
  95. if not success:
  96. return False, f"Failed to add remote: {stderr or stdout}"
  97. # Enable sparse-checkout (non-cone mode to exclude root files)
  98. success, stdout, stderr = _run_git_command(
  99. ["sparse-checkout", "init", "--no-cone"],
  100. cwd=target_path
  101. )
  102. if not success:
  103. return False, f"Failed to enable sparse-checkout: {stderr or stdout}"
  104. # Set sparse-checkout to specific directory (non-cone uses patterns)
  105. success, stdout, stderr = _run_git_command(
  106. ["sparse-checkout", "set", f"{sparse_dir}/*"],
  107. cwd=target_path
  108. )
  109. if not success:
  110. return False, f"Failed to set sparse-checkout directory: {stderr or stdout}"
  111. # Fetch specific branch
  112. fetch_args = ["fetch", "--depth", "1", "origin"]
  113. if branch:
  114. fetch_args.append(f"{branch}:{branch}")
  115. else:
  116. fetch_args.append("main:main")
  117. success, stdout, stderr = _run_git_command(fetch_args, cwd=target_path)
  118. if not success:
  119. return False, f"Fetch failed: {stderr or stdout}"
  120. # Checkout the branch
  121. checkout_branch = branch if branch else "main"
  122. success, stdout, stderr = _run_git_command(
  123. ["checkout", checkout_branch],
  124. cwd=target_path
  125. )
  126. if not success:
  127. return False, f"Checkout failed: {stderr or stdout}"
  128. # Done! Files are in target_path/sparse_dir/
  129. return True, "Cloned successfully (sparse)"
  130. else:
  131. return False, f"Failed to initialize: {stderr or stdout}"
  132. else:
  133. # Regular full clone
  134. clone_args = ["clone", "--depth", "1"]
  135. if branch:
  136. clone_args.extend(["--branch", branch])
  137. clone_args.extend([url, str(target_path)])
  138. success, stdout, stderr = _run_git_command(clone_args)
  139. if success:
  140. return True, "Cloned successfully"
  141. else:
  142. error_msg = stderr or stdout
  143. logger.error(f"Failed to clone library '{name}': {error_msg}")
  144. return False, f"Clone failed: {error_msg}"
  145. @app.command()
  146. def update(
  147. library_name: Optional[str] = Argument(
  148. None,
  149. help="Name of specific library to update (updates all if not specified)"
  150. ),
  151. verbose: bool = Option(False, "--verbose", "-v", help="Show detailed output")
  152. ) -> None:
  153. """Update library repositories by cloning or pulling from git.
  154. This command syncs all configured libraries from their git repositories.
  155. If a library doesn't exist locally, it will be cloned. If it exists, it will be pulled.
  156. """
  157. config = ConfigManager()
  158. libraries = config.get_libraries()
  159. if not libraries:
  160. console.print("[yellow]No libraries configured.[/yellow]")
  161. console.print("Libraries are auto-configured on first run with a default library.")
  162. return
  163. # Filter to specific library if requested
  164. if library_name:
  165. libraries = [lib for lib in libraries if lib.get("name") == library_name]
  166. if not libraries:
  167. console_err.print(f"[red]Error:[/red] Library '{library_name}' not found in configuration")
  168. return
  169. libraries_path = config.get_libraries_path()
  170. # Create results table
  171. results = []
  172. with Progress(
  173. SpinnerColumn(),
  174. TextColumn("[progress.description]{task.description}"),
  175. console=console,
  176. ) as progress:
  177. for lib in libraries:
  178. name = lib.get("name")
  179. url = lib.get("url")
  180. branch = lib.get("branch")
  181. directory = lib.get("directory", "library")
  182. enabled = lib.get("enabled", True)
  183. if not enabled:
  184. if verbose:
  185. console.print(f"[dim]Skipping disabled library: {name}[/dim]")
  186. results.append((name, "Skipped (disabled)", False))
  187. continue
  188. task = progress.add_task(f"Updating {name}...", total=None)
  189. # Target path: ~/.config/boilerplates/libraries/{name}/
  190. target_path = libraries_path / name
  191. # Clone or pull the repository with sparse-checkout if directory is specified
  192. success, message = _clone_or_pull_repo(name, url, target_path, branch, directory)
  193. results.append((name, message, success))
  194. progress.remove_task(task)
  195. if verbose:
  196. if success:
  197. display.display_success(f"{name}: {message}")
  198. else:
  199. display.display_error(f"{name}: {message}")
  200. # Display summary table
  201. if not verbose:
  202. table = Table(title="Library Update Summary", show_header=True)
  203. table.add_column("Library", style="cyan", no_wrap=True)
  204. table.add_column("Status")
  205. for name, message, success in results:
  206. status_style = "green" if success else "red"
  207. status_icon = IconManager.get_status_icon("success" if success else "error")
  208. table.add_row(name, f"[{status_style}]{status_icon} {message}[/{status_style}]")
  209. console.print(table)
  210. # Summary
  211. total = len(results)
  212. successful = sum(1 for _, _, success in results if success)
  213. if successful == total:
  214. console.print(f"\n[green]All libraries updated successfully ({successful}/{total})[/green]")
  215. elif successful > 0:
  216. console.print(f"\n[yellow]Partially successful: {successful}/{total} libraries updated[/yellow]")
  217. else:
  218. console.print(f"\n[red]Failed to update libraries[/red]")
  219. @app.command()
  220. def list() -> None:
  221. """List all configured libraries."""
  222. config = ConfigManager()
  223. libraries = config.get_libraries()
  224. if not libraries:
  225. console.print("[yellow]No libraries configured.[/yellow]")
  226. return
  227. table = Table(title="Configured Libraries", show_header=True)
  228. table.add_column("Name", style="cyan", no_wrap=True)
  229. table.add_column("URL", style="blue")
  230. table.add_column("Branch", style="yellow")
  231. table.add_column("Directory", style="magenta")
  232. table.add_column("Status", style="green")
  233. libraries_path = config.get_libraries_path()
  234. for lib in libraries:
  235. name = lib.get("name", "")
  236. url = lib.get("url", "")
  237. branch = lib.get("branch", "main")
  238. directory = lib.get("directory", "library")
  239. enabled = lib.get("enabled", True)
  240. # Check if library exists locally
  241. library_base = libraries_path / name
  242. if directory and directory != ".":
  243. library_path = library_base / directory
  244. else:
  245. library_path = library_base
  246. exists = library_path.exists()
  247. status_parts = []
  248. if not enabled:
  249. status_parts.append("[dim]disabled[/dim]")
  250. elif exists:
  251. status_parts.append("[green]synced[/green]")
  252. else:
  253. status_parts.append("[yellow]not synced[/yellow]")
  254. status = " ".join(status_parts)
  255. table.add_row(name, url, branch, directory, status)
  256. console.print(table)
  257. @app.command()
  258. def add(
  259. name: str = Argument(..., help="Unique name for the library"),
  260. url: str = Argument(..., help="Git repository URL"),
  261. branch: str = Option("main", "--branch", "-b", help="Git branch to use"),
  262. directory: str = Option("library", "--directory", "-d", help="Directory within repo containing templates (metadata only)"),
  263. enabled: bool = Option(True, "--enabled/--disabled", help="Enable or disable the library"),
  264. sync: bool = Option(True, "--sync/--no-sync", help="Sync the library after adding")
  265. ) -> None:
  266. """Add a new library to the configuration."""
  267. config = ConfigManager()
  268. try:
  269. config.add_library(name, url, directory, branch, enabled)
  270. display.display_success(f"Added library '{name}'")
  271. if sync and enabled:
  272. console.print(f"\nSyncing library '{name}'...")
  273. # Call update for this specific library
  274. update(library_name=name, verbose=True)
  275. except ConfigError as e:
  276. display.display_error(str(e))
  277. @app.command()
  278. def remove(
  279. name: str = Argument(..., help="Name of the library to remove"),
  280. keep_files: bool = Option(False, "--keep-files", help="Keep the local library files (don't delete)")
  281. ) -> None:
  282. """Remove a library from the configuration and delete its local files."""
  283. config = ConfigManager()
  284. try:
  285. # Remove from config
  286. config.remove_library(name)
  287. display.display_success(f"Removed library '{name}' from configuration")
  288. # Delete local files unless --keep-files is specified
  289. if not keep_files:
  290. libraries_path = config.get_libraries_path()
  291. library_path = libraries_path / name
  292. if library_path.exists():
  293. import shutil
  294. shutil.rmtree(library_path)
  295. display.display_success(f"Deleted local files at {library_path}")
  296. else:
  297. display.display_info(f"No local files found at {library_path}")
  298. except ConfigError as e:
  299. display.display_error(str(e))
  300. # Register the repo command with the CLI
  301. def register_cli(parent_app: Typer) -> None:
  302. """Register the repo command with the parent Typer app."""
  303. parent_app.add_typer(app, name="repo")