From 556aedff8755b8e778119387fbf83fff4c63892c Mon Sep 17 00:00:00 2001 From: Val Redchenko Date: Mon, 26 Jan 2026 11:19:10 +0000 Subject: [PATCH] feat(sync): clone missing repos before pulling updates --- packages/smartem-workspace/pyproject.toml | 2 +- .../smartem_workspace/__init__.py | 2 +- .../smartem_workspace/cli.py | 20 ++++- .../smartem_workspace/commands/sync.py | 79 ++++++++++++++----- packages/smartem-workspace/uv.lock | 2 +- 5 files changed, 81 insertions(+), 24 deletions(-) diff --git a/packages/smartem-workspace/pyproject.toml b/packages/smartem-workspace/pyproject.toml index 27ae3d8..872bcb0 100644 --- a/packages/smartem-workspace/pyproject.toml +++ b/packages/smartem-workspace/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "smartem-workspace" -version = "0.4.0" +version = "0.5.0" description = "CLI tool to automate SmartEM multi-repo workspace setup" readme = "README.md" license = "Apache-2.0" diff --git a/packages/smartem-workspace/smartem_workspace/__init__.py b/packages/smartem-workspace/smartem_workspace/__init__.py index 241b145..642d673 100644 --- a/packages/smartem-workspace/smartem_workspace/__init__.py +++ b/packages/smartem-workspace/smartem_workspace/__init__.py @@ -1,3 +1,3 @@ """SmartEM workspace setup CLI tool.""" -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/packages/smartem-workspace/smartem_workspace/cli.py b/packages/smartem-workspace/smartem_workspace/cli.py index bea9acf..ac0d57b 100644 --- a/packages/smartem-workspace/smartem_workspace/cli.py +++ b/packages/smartem-workspace/smartem_workspace/cli.py @@ -259,12 +259,20 @@ def sync( bool, typer.Option("--dry-run", "-n", help="Show what would be done without making changes"), ] = False, + git_ssh: Annotated[ + bool, + typer.Option("--git-ssh", help="Force SSH URLs for cloning (default: auto-detect)"), + ] = False, + git_https: Annotated[ + bool, + typer.Option("--git-https", help="Force HTTPS URLs for cloning (default: auto-detect)"), + ] = False, path: Annotated[ Path | None, typer.Option("--path", "-p", help="Workspace path (auto-detected if not specified)"), ] = None, ) -> None: - """Pull latest changes from all cloned repositories.""" + """Sync workspace: clone missing repos and pull updates for existing ones.""" out = get_console() workspace_path = path or find_workspace_root() if workspace_path is None: @@ -276,11 +284,17 @@ def sync( out.print("[red]Failed to load configuration[/red]") raise typer.Exit(1) + if git_ssh and git_https: + out.print("[red]Cannot specify both --git-ssh and --git-https[/red]") + raise typer.Exit(1) + + use_ssh: bool | None = True if git_ssh else (False if git_https else None) + out.print("[bold blue]SmartEM Workspace Sync[/bold blue]") out.print(f"Workspace: {workspace_path}") - results = sync_all_repos(workspace_path, config, dry_run=dry_run) - print_sync_results(results) + results = sync_all_repos(workspace_path, config, out, dry_run=dry_run, use_ssh=use_ssh) + print_sync_results(results, out) errors = sum(1 for r in results if r.status == "error") if errors: diff --git a/packages/smartem-workspace/smartem_workspace/commands/sync.py b/packages/smartem-workspace/smartem_workspace/commands/sync.py index 9138638..36a7694 100644 --- a/packages/smartem-workspace/smartem_workspace/commands/sync.py +++ b/packages/smartem-workspace/smartem_workspace/commands/sync.py @@ -7,9 +7,10 @@ from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn -from smartem_workspace.config.schema import ReposConfig -from smartem_workspace.setup.repos import get_local_dir +from smartem_workspace.config.schema import Organization, ReposConfig, Repository +from smartem_workspace.setup.repos import clone_repo, get_local_dir from smartem_workspace.utils.git import ( + check_github_ssh_auth, fetch_remote, get_commits_behind, get_current_branch, @@ -17,14 +18,12 @@ run_git_command, ) -console = Console() - @dataclass class SyncResult: repo_name: str org_name: str - status: Literal["updated", "up-to-date", "error", "skipped", "dry-run"] + status: Literal["updated", "up-to-date", "error", "skipped", "dry-run", "cloned"] message: str commits_behind: int = 0 @@ -69,16 +68,19 @@ def sync_single_repo(repo_path: Path, dry_run: bool = False) -> SyncResult: def sync_all_repos( workspace_path: Path, config: ReposConfig, + console: Console, dry_run: bool = False, + use_ssh: bool | None = None, ) -> list[SyncResult]: repos_dir = workspace_path / "repos" - results = [] + results: list[SyncResult] = [] if not repos_dir.exists(): - console.print("[red]repos directory not found[/red]") - return results + repos_dir.mkdir(parents=True, exist_ok=True) + + missing_repos: list[tuple[Organization, Repository, Path]] = [] + existing_repos: list[tuple[str, str, Path]] = [] - repo_paths = [] for org in config.organizations: local_dir = get_local_dir(org) org_dir = repos_dir / local_dir @@ -86,14 +88,50 @@ def sync_all_repos( for repo in org.repos: repo_path = org_dir / repo.name if repo_path.exists(): - repo_paths.append((org.name, repo.name, repo_path)) - - if not repo_paths: - console.print("[yellow]No cloned repositories found[/yellow]") + existing_repos.append((org.name, repo.name, repo_path)) + else: + missing_repos.append((org, repo, repo_path)) + + if missing_repos: + github_ssh_ok: bool | None = None + if use_ssh is None: + has_github = any(org.provider == "github" for org, _, _ in missing_repos) + if has_github: + github_ssh_ok = check_github_ssh_auth() + + action = "Would clone" if dry_run else "Cloning" + console.print(f"\n[bold]{action} {len(missing_repos)} missing repositories...[/bold]\n") + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=True, + ) as progress: + task = progress.add_task("Starting...", total=len(missing_repos)) + + for org, repo, repo_path in missing_repos: + progress.update(task, description=f"{org.name}/{repo.name}") + + if dry_run: + results.append(SyncResult(repo.name, org.name, "dry-run", "Would clone")) + else: + success = clone_repo(repo, org, repos_dir, use_ssh, github_ssh_ok) + if success: + results.append(SyncResult(repo.name, org.name, "cloned", "Cloned successfully")) + existing_repos.append((org.name, repo.name, repo_path)) + else: + results.append(SyncResult(repo.name, org.name, "error", "Clone failed")) + + progress.advance(task) + + if not existing_repos: + if not missing_repos: + console.print("[yellow]No repositories configured[/yellow]") return results action = "Checking" if dry_run else "Syncing" - console.print(f"\n[bold]{action} {len(repo_paths)} repositories...[/bold]\n") + console.print(f"\n[bold]{action} {len(existing_repos)} repositories...[/bold]\n") with Progress( SpinnerColumn(), @@ -101,9 +139,9 @@ def sync_all_repos( console=console, transient=True, ) as progress: - task = progress.add_task("Starting...", total=len(repo_paths)) + task = progress.add_task("Starting...", total=len(existing_repos)) - for org_name, repo_name, repo_path in repo_paths: + for org_name, repo_name, repo_path in existing_repos: progress.update(task, description=f"{org_name}/{repo_name}") result = sync_single_repo(repo_path, dry_run=dry_run) results.append(result) @@ -112,7 +150,8 @@ def sync_all_repos( return results -def print_sync_results(results: list[SyncResult]) -> None: +def print_sync_results(results: list[SyncResult], console: Console) -> None: + cloned = sum(1 for r in results if r.status == "cloned") updated = sum(1 for r in results if r.status == "updated") up_to_date = sum(1 for r in results if r.status == "up-to-date") skipped = sum(1 for r in results if r.status == "skipped") @@ -122,7 +161,9 @@ def print_sync_results(results: list[SyncResult]) -> None: for result in results: full_name = f"{result.org_name}/{result.repo_name}" - if result.status == "updated": + if result.status == "cloned": + console.print(f" [green]+[/green] {full_name}: {result.message}") + elif result.status == "updated": console.print(f" [green]\u2713[/green] {full_name}: {result.message}") elif result.status == "up-to-date": console.print(f" [dim]\u2713 {full_name}: {result.message}[/dim]") @@ -135,6 +176,8 @@ def print_sync_results(results: list[SyncResult]) -> None: console.print() parts = [] + if cloned: + parts.append(f"[green]{cloned} cloned[/green]") if updated: parts.append(f"[green]{updated} updated[/green]") if would_update: diff --git a/packages/smartem-workspace/uv.lock b/packages/smartem-workspace/uv.lock index b5ee8de..3261552 100644 --- a/packages/smartem-workspace/uv.lock +++ b/packages/smartem-workspace/uv.lock @@ -496,7 +496,7 @@ wheels = [ [[package]] name = "smartem-workspace" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "httpx" },