Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/smartem-workspace/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/smartem-workspace/smartem_workspace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""SmartEM workspace setup CLI tool."""

__version__ = "0.4.0"
__version__ = "0.5.0"
20 changes: 17 additions & 3 deletions packages/smartem-workspace/smartem_workspace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
79 changes: 61 additions & 18 deletions packages/smartem-workspace/smartem_workspace/commands/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,23 @@
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,
has_uncommitted_changes,
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

Expand Down Expand Up @@ -69,41 +68,80 @@ 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

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(),
TextColumn("[progress.description]{task.description}"),
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)
Expand All @@ -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")
Expand All @@ -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]")
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/smartem-workspace/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.