repo.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. """Repository management module for syncing library repositories."""
  2. from __future__ import annotations
  3. import logging
  4. import shutil
  5. import subprocess
  6. from pathlib import Path
  7. from rich.progress import SpinnerColumn, TextColumn
  8. from rich.table import Table
  9. from typer import Argument, Option, Typer
  10. from ..core.config import ConfigManager, LibraryConfig
  11. from ..core.display import DisplayManager, IconManager
  12. from ..core.exceptions import ConfigError
  13. logger = logging.getLogger(__name__)
  14. display = DisplayManager()
  15. app = Typer(help="Manage library repositories")
  16. def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[bool, str, str]:
  17. """Run a git command and return the result.
  18. Args:
  19. args: Git command arguments (without 'git' prefix)
  20. cwd: Working directory for the command
  21. Returns:
  22. Tuple of (success, stdout, stderr)
  23. """
  24. try:
  25. result = subprocess.run(
  26. ["git", *args],
  27. check=False,
  28. cwd=cwd,
  29. capture_output=True,
  30. text=True,
  31. timeout=300, # 5 minute timeout
  32. )
  33. return result.returncode == 0, result.stdout, result.stderr
  34. except subprocess.TimeoutExpired:
  35. return False, "", "Command timed out after 5 minutes"
  36. except FileNotFoundError:
  37. return False, "", "Git command not found. Please install git."
  38. except Exception as e:
  39. return False, "", str(e)
  40. def _clone_or_pull_repo(
  41. name: str,
  42. url: str,
  43. target_path: Path,
  44. branch: str | None = None,
  45. sparse_dir: str | None = None,
  46. ) -> tuple[bool, str]:
  47. """Clone or pull a git repository with optional sparse-checkout.
  48. Args:
  49. name: Library name
  50. url: Git repository URL
  51. target_path: Target directory for the repository
  52. branch: Git branch to clone/pull (optional)
  53. sparse_dir: Directory to sparse-checkout (optional, use None or "." for full clone)
  54. Returns:
  55. Tuple of (success, message)
  56. """
  57. if target_path.exists() and (target_path / ".git").exists():
  58. return _pull_repo_updates(name, target_path, branch)
  59. return _clone_new_repo(name, url, target_path, branch, sparse_dir)
  60. def _pull_repo_updates(name: str, target_path: Path, branch: str | None) -> tuple[bool, str]:
  61. """Pull updates for an existing repository."""
  62. logger.debug(f"Pulling updates for library '{name}' at {target_path}")
  63. pull_branch = branch if branch else "main"
  64. success, stdout, stderr = _run_git_command(["pull", "--ff-only", "origin", pull_branch], cwd=target_path)
  65. if not success:
  66. error_msg = stderr or stdout
  67. logger.error(f"Failed to pull library '{name}': {error_msg}")
  68. return False, f"Pull failed: {error_msg}"
  69. if "Already up to date" in stdout or "Already up-to-date" in stdout:
  70. return True, "Already up to date"
  71. return True, "Updated successfully"
  72. def _clone_new_repo(
  73. name: str, url: str, target_path: Path, branch: str | None, sparse_dir: str | None
  74. ) -> tuple[bool, str]:
  75. """Clone a new repository, optionally with sparse-checkout."""
  76. logger.debug(f"Cloning library '{name}' from {url} to {target_path}")
  77. target_path.parent.mkdir(parents=True, exist_ok=True)
  78. use_sparse = sparse_dir and sparse_dir != "."
  79. if use_sparse:
  80. return _clone_sparse_repo(url, target_path, branch, sparse_dir)
  81. return _clone_full_repo(name, url, target_path, branch)
  82. def _clone_sparse_repo(url: str, target_path: Path, branch: str | None, sparse_dir: str) -> tuple[bool, str]:
  83. """Clone repository with sparse-checkout."""
  84. logger.debug(f"Using sparse-checkout for directory: {sparse_dir}")
  85. target_path.mkdir(parents=True, exist_ok=True)
  86. # Define git operations to perform sequentially
  87. operations = [
  88. (["init"], "Failed to initialize repo"),
  89. (["remote", "add", "origin", url], "Failed to add remote"),
  90. (["sparse-checkout", "init", "--no-cone"], "Failed to enable sparse-checkout"),
  91. (
  92. ["sparse-checkout", "set", f"{sparse_dir}/*"],
  93. "Failed to set sparse-checkout directory",
  94. ),
  95. ]
  96. # Execute initial operations
  97. for cmd, error_msg in operations:
  98. success, stdout, stderr = _run_git_command(cmd, cwd=target_path)
  99. if not success:
  100. return False, f"{error_msg}: {stderr or stdout}"
  101. # Fetch and checkout
  102. fetch_branch = branch if branch else "main"
  103. success, stdout, stderr = _run_git_command(["fetch", "--depth", "1", "origin", fetch_branch], cwd=target_path)
  104. if not success:
  105. return False, f"Fetch failed: {stderr or stdout}"
  106. success, stdout, stderr = _run_git_command(["checkout", fetch_branch], cwd=target_path)
  107. result_success = success
  108. result_msg = "Cloned successfully (sparse)" if success else f"Checkout failed: {stderr or stdout}"
  109. return result_success, result_msg
  110. def _clone_full_repo(name: str, url: str, target_path: Path, branch: str | None) -> tuple[bool, str]:
  111. """Clone full repository."""
  112. clone_args = ["clone", "--depth", "1"]
  113. if branch:
  114. clone_args.extend(["--branch", branch])
  115. clone_args.extend([url, str(target_path)])
  116. success, stdout, stderr = _run_git_command(clone_args)
  117. if success:
  118. return True, "Cloned successfully"
  119. error_msg = stderr or stdout
  120. logger.error(f"Failed to clone library '{name}': {error_msg}")
  121. return False, f"Clone failed: {error_msg}"
  122. def _process_library_update(lib: dict, libraries_path: Path, progress, verbose: bool) -> tuple[str, str, bool]:
  123. """Process a single library update and return result."""
  124. name = lib.get("name")
  125. lib_type = lib.get("type", "git")
  126. enabled = lib.get("enabled", True)
  127. if not enabled:
  128. if verbose:
  129. display.text(f"Skipping disabled library: {name}", style="dim")
  130. return (name, "Skipped (disabled)", False)
  131. if lib_type == "static":
  132. if verbose:
  133. display.text(f"Skipping static library: {name} (no sync needed)", style="dim")
  134. return (name, "N/A (static)", True)
  135. # Handle git libraries
  136. url = lib.get("url")
  137. branch = lib.get("branch")
  138. directory = lib.get("directory", "library")
  139. task = progress.add_task(f"Updating {name}...", total=None)
  140. target_path = libraries_path / name
  141. success, message = _clone_or_pull_repo(name, url, target_path, branch, directory)
  142. progress.remove_task(task)
  143. if verbose:
  144. if success:
  145. display.success(f"{name}: {message}")
  146. else:
  147. display.error(f"{name}: {message}")
  148. return (name, message, success)
  149. def _display_update_summary(results: list[tuple[str, str, bool]]) -> None:
  150. """Display update summary."""
  151. total = len(results)
  152. successful = sum(1 for _, _, success in results if success)
  153. display.text("")
  154. if successful == total:
  155. display.text(f"All libraries updated successfully ({successful}/{total})", style="green")
  156. elif successful > 0:
  157. display.text(
  158. f"Partially successful: {successful}/{total} libraries updated",
  159. style="yellow",
  160. )
  161. else:
  162. display.text("Failed to update libraries", style="red")
  163. @app.command()
  164. def update(
  165. library_name: str | None = Argument(None, help="Name of specific library to update (updates all if not specified)"),
  166. verbose: bool = Option(False, "--verbose", "-v", help="Show detailed output"),
  167. ) -> None:
  168. """Update library repositories by cloning or pulling from git.
  169. This command syncs all configured libraries from their git repositories.
  170. If a library doesn't exist locally, it will be cloned. If it exists, it will be pulled.
  171. """
  172. config = ConfigManager()
  173. libraries = config.get_libraries()
  174. if not libraries:
  175. display.warning("No libraries configured")
  176. display.text("Libraries are auto-configured on first run with a default library.")
  177. return
  178. # Filter to specific library if requested
  179. if library_name:
  180. libraries = [lib for lib in libraries if lib.get("name") == library_name]
  181. if not libraries:
  182. display.error(f"Library '{library_name}' not found in configuration")
  183. return
  184. libraries_path = config.get_libraries_path()
  185. results = []
  186. with display.progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}")) as progress:
  187. for lib in libraries:
  188. result = _process_library_update(lib, libraries_path, progress, verbose)
  189. results.append(result)
  190. # Display summary table
  191. if not verbose:
  192. display.display_status_table("Library Update Summary", results, columns=("Library", "Status"))
  193. _display_update_summary(results)
  194. def _get_library_path_for_git(lib: dict, libraries_path: Path, name: str) -> Path:
  195. """Get library path for git library type."""
  196. directory = lib.get("directory", "library")
  197. library_base = libraries_path / name
  198. if directory and directory != ".":
  199. return library_base / directory
  200. return library_base
  201. def _get_library_path_for_static(lib: dict, config: ConfigManager) -> Path:
  202. """Get library path for static library type."""
  203. url_or_path = lib.get("path", "")
  204. library_path = Path(url_or_path).expanduser()
  205. if not library_path.is_absolute():
  206. library_path = (config.config_path.parent / library_path).resolve()
  207. return library_path
  208. def _get_library_info(lib: dict, config: ConfigManager, libraries_path: Path) -> tuple[str, str, str, str, str, str]:
  209. """Extract library information based on type."""
  210. name = lib.get("name", "")
  211. lib_type = lib.get("type", "git")
  212. enabled = lib.get("enabled", True)
  213. if lib_type == "git":
  214. url_or_path = lib.get("url", "")
  215. branch = lib.get("branch", "main")
  216. directory = lib.get("directory", "library")
  217. library_path = _get_library_path_for_git(lib, libraries_path, name)
  218. exists = library_path.exists()
  219. type_icon = IconManager.UI_LIBRARY_GIT
  220. elif lib_type == "static":
  221. url_or_path = lib.get("path", "")
  222. branch = "-"
  223. directory = "-"
  224. library_path = _get_library_path_for_static(lib, config)
  225. exists = library_path.exists()
  226. type_icon = IconManager.UI_LIBRARY_STATIC
  227. else:
  228. # Unknown type
  229. url_or_path = "<unknown type>"
  230. branch = "-"
  231. directory = "-"
  232. exists = False
  233. type_icon = "?"
  234. # Build status string
  235. status_parts = []
  236. if not enabled:
  237. status_parts.append("[dim]disabled[/dim]")
  238. elif exists:
  239. status_parts.append("[green]available[/green]")
  240. else:
  241. status_parts.append("[yellow]not found[/yellow]")
  242. status = " ".join(status_parts)
  243. type_display = f"{type_icon} {lib_type}"
  244. return url_or_path, branch, directory, type_display, type_icon, status
  245. @app.command()
  246. def list() -> None:
  247. """List all configured libraries."""
  248. config = ConfigManager()
  249. libraries = config.get_libraries()
  250. if not libraries:
  251. display.text("No libraries configured.", style="yellow")
  252. return
  253. settings = display.settings
  254. table = Table(
  255. title="Configured Libraries",
  256. show_header=True,
  257. header_style=settings.STYLE_TABLE_HEADER,
  258. )
  259. table.add_column("Name", style="cyan", no_wrap=True)
  260. table.add_column("URL/Path", style="blue")
  261. table.add_column("Branch", style="yellow")
  262. table.add_column("Directory", style="magenta")
  263. table.add_column("Type", style="cyan")
  264. table.add_column("Status", style="green")
  265. libraries_path = config.get_libraries_path()
  266. for lib in libraries:
  267. name = lib.get("name", "")
  268. url_or_path, branch, directory, type_display, _type_icon, status = _get_library_info(
  269. lib, config, libraries_path
  270. )
  271. table.add_row(name, url_or_path, branch, directory, type_display, status)
  272. display.print_table(table)
  273. @app.command()
  274. def add(
  275. name: str = Argument(..., help="Unique name for the library"),
  276. *,
  277. library_type: str | None = None,
  278. url: str | None = None,
  279. branch: str = "main",
  280. directory: str = "library",
  281. path: str | None = None,
  282. enabled: bool = Option(True, "--enabled/--disabled", help="Enable or disable the library"),
  283. sync: bool = Option(True, "--sync/--no-sync", help="Sync after adding (git only)"),
  284. ) -> None:
  285. """Add a new library to the configuration.
  286. Examples:
  287. # Add a git library
  288. repo add mylib --type git --url https://github.com/user/templates.git
  289. # Add a static library
  290. repo add local --type static --path ~/my-templates
  291. """
  292. config = ConfigManager()
  293. try:
  294. if library_type == "git":
  295. if not url:
  296. display.error("--url is required for git libraries")
  297. return
  298. lib_config = LibraryConfig(
  299. name=name,
  300. library_type="git",
  301. url=url,
  302. branch=branch,
  303. directory=directory,
  304. enabled=enabled,
  305. )
  306. elif library_type == "static":
  307. if not path:
  308. display.error("--path is required for static libraries")
  309. return
  310. lib_config = LibraryConfig(
  311. name=name,
  312. library_type="static",
  313. path=path,
  314. enabled=enabled,
  315. )
  316. else:
  317. display.error(f"Invalid library type: {library_type}. Must be 'git' or 'static'.")
  318. return
  319. config.add_library(lib_config)
  320. display.success(f"Added {library_type} library '{name}'")
  321. if library_type == "git" and sync and enabled:
  322. display.text(f"\nSyncing library '{name}'...")
  323. update(library_name=name, verbose=True)
  324. elif library_type == "static":
  325. display.info(f"Static library points to: {path}")
  326. except ConfigError as e:
  327. display.error(str(e))
  328. @app.command()
  329. def remove(
  330. name: str = Argument(..., help="Name of the library to remove"),
  331. keep_files: bool = Option(False, "--keep-files", help="Keep the local library files (don't delete)"),
  332. ) -> None:
  333. """Remove a library from the configuration and delete its local files."""
  334. config = ConfigManager()
  335. try:
  336. # Remove from config
  337. config.remove_library(name)
  338. display.success(f"Removed library '{name}' from configuration")
  339. # Delete local files unless --keep-files is specified
  340. if not keep_files:
  341. libraries_path = config.get_libraries_path()
  342. library_path = libraries_path / name
  343. if library_path.exists():
  344. shutil.rmtree(library_path)
  345. display.success(f"Deleted local files at {library_path}")
  346. else:
  347. display.info(f"No local files found at {library_path}")
  348. except ConfigError as e:
  349. display.error(str(e))
  350. # Register the repo command with the CLI
  351. def register_cli(parent_app: Typer) -> None:
  352. """Register the repo command with the parent Typer app."""
  353. parent_app.add_typer(app, name="repo", rich_help_panel="Configuration Commands")