repo.py 14 KB

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