repo.py 16 KB

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