From 664e3e493b5e6518ad6ffc741464d5b289008536 Mon Sep 17 00:00:00 2001 From: Val Redchenko Date: Tue, 13 Jan 2026 22:37:13 +0000 Subject: [PATCH 1/4] chore: add Git Commit Guidelines to CLAUDE.md Adds guidelines for commit messages: - No Claude attribution in commits - Use conventional commits format - Focus on the "why" not the "what" Closes #108 --- claude-code/CLAUDE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/claude-code/CLAUDE.md b/claude-code/CLAUDE.md index bfba253..2a16c9e 100644 --- a/claude-code/CLAUDE.md +++ b/claude-code/CLAUDE.md @@ -279,3 +279,11 @@ Refer to `claude-config/smartem-decisions/REPO-GUIDELINES.md` for: - Follow FandanGO plugin conventions - Reference peer plugins in repos/FragmentScreen/ for patterns + +--- + +## Git Commit Guidelines + +- **No Claude attribution**: Do NOT add `Co-Authored-By: Claude` or any similar attribution lines to commit messages. Commits should appear as normal developer commits. +- Write clear, concise commit messages following conventional commits format when appropriate (feat, fix, docs, refactor, etc.) +- Focus on the "why" not the "what" in commit body From d91a5a41c3859061c06c3baab11dd37c1cb773ee Mon Sep 17 00:00:00 2001 From: Val Redchenko Date: Tue, 13 Jan 2026 22:45:59 +0000 Subject: [PATCH 2/4] fix(smartem-workspace): symlink CLAUDE.md instead of copying Changes `smartem-workspace init` to create a symlink for CLAUDE.md rather than copying it. This ensures workspace stays in sync with the versioned config in smartem-devtools. --- .../smartem-workspace/smartem_workspace/setup/claude.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/smartem-workspace/smartem_workspace/setup/claude.py b/packages/smartem-workspace/smartem_workspace/setup/claude.py index 39563af..6bae15f 100644 --- a/packages/smartem-workspace/smartem_workspace/setup/claude.py +++ b/packages/smartem-workspace/smartem_workspace/setup/claude.py @@ -83,10 +83,11 @@ def setup_claude_config( claude_md_target = workspace_path / "CLAUDE.md" if claude_md_source.exists() and not claude_md_target.exists(): - import shutil - - shutil.copy(claude_md_source, claude_md_target) - console.print(" [green]Copied CLAUDE.md[/green]") + try: + os.symlink(str(claude_md_source.resolve()), str(claude_md_target)) + console.print(" [green]Created symlink: CLAUDE.md[/green]") + except OSError as e: + console.print(f" [yellow]Could not create CLAUDE.md symlink: {e}[/yellow]") elif claude_md_target.exists(): console.print(" [dim]CLAUDE.md already exists[/dim]") From f004185bb6a1d8d81da49bbf6fd84981e3dedac9 Mon Sep 17 00:00:00 2001 From: Val Redchenko Date: Wed, 14 Jan 2026 19:02:50 +0000 Subject: [PATCH 3/4] feat(smartem-workspace): add check and sync commands Implements workspace verification and repository synchronization: - `smartem-workspace check [--scope] [--fix]` - verify workspace setup - Scopes: claude, repos, serena, all - Validates symlinks, directories, config files, cloned repos - --fix repairs broken/missing symlinks - `smartem-workspace sync [--dry-run]` - pull latest from all repos - Fetches and pulls all cloned repositories - Skips repos with uncommitted changes or not on main/master - --dry-run shows what would be pulled Also fixes skill paths in core/repos.json to include claude-code/ prefix. Closes #108 --- core/repos.json | 14 +- .../smartem_workspace/cli.py | 145 ++++++++- .../smartem_workspace/commands/__init__.py | 13 + .../smartem_workspace/commands/check.py | 279 ++++++++++++++++++ .../smartem_workspace/commands/sync.py | 149 ++++++++++ .../smartem_workspace/config/loader.py | 14 +- .../smartem_workspace/utils/git.py | 72 +++++ 7 files changed, 668 insertions(+), 18 deletions(-) create mode 100644 packages/smartem-workspace/smartem_workspace/commands/__init__.py create mode 100644 packages/smartem-workspace/smartem_workspace/commands/check.py create mode 100644 packages/smartem-workspace/smartem_workspace/commands/sync.py diff --git a/core/repos.json b/core/repos.json index 3f5c570..01d8e95 100644 --- a/core/repos.json +++ b/core/repos.json @@ -361,13 +361,13 @@ ], "claudeConfig": { "skills": [ - { "name": "database-admin", "path": "shared/skills/database-admin" }, - { "name": "devops", "path": "shared/skills/devops" }, - { "name": "technical-writer", "path": "shared/skills/technical-writer" }, - { "name": "git", "path": "shared/skills/git" }, - { "name": "github", "path": "shared/skills/github" }, - { "name": "ascii-art", "path": "shared/skills/ascii-art" }, - { "name": "playwright-skill", "path": "smartem-frontend/skills/playwright-skill" } + { "name": "database-admin", "path": "claude-code/shared/skills/database-admin" }, + { "name": "devops", "path": "claude-code/shared/skills/devops" }, + { "name": "technical-writer", "path": "claude-code/shared/skills/technical-writer" }, + { "name": "git", "path": "claude-code/shared/skills/git" }, + { "name": "github", "path": "claude-code/shared/skills/github" }, + { "name": "ascii-art", "path": "claude-code/shared/skills/ascii-art" }, + { "name": "playwright-skill", "path": "claude-code/smartem-frontend/skills/playwright-skill" } ], "defaultPermissions": { "allow": [ diff --git a/packages/smartem-workspace/smartem_workspace/cli.py b/packages/smartem-workspace/smartem_workspace/cli.py index f53b76e..44bb75d 100644 --- a/packages/smartem-workspace/smartem_workspace/cli.py +++ b/packages/smartem-workspace/smartem_workspace/cli.py @@ -6,8 +6,16 @@ import typer from rich.console import Console +from smartem_workspace.commands.check import ( + CheckScope, + apply_fixes, + print_report, + run_checks, +) +from smartem_workspace.commands.sync import print_sync_results, sync_all_repos from smartem_workspace.config.loader import load_config from smartem_workspace.setup.bootstrap import bootstrap_workspace +from smartem_workspace.utils.paths import find_workspace_root app = typer.Typer( name="smartem-workspace", @@ -67,17 +75,138 @@ def init( @app.command() -def sync() -> None: - """Sync existing repos (git pull).""" - console.print("[yellow]Not implemented yet[/yellow]") - raise typer.Exit(1) +def check( + scope: Annotated[ + str | None, + typer.Option("--scope", "-s", help="Check scope: claude, repos, serena, or all"), + ] = None, + fix: Annotated[ + bool, + typer.Option("--fix", help="Attempt to fix issues"), + ] = False, + path: Annotated[ + Path | None, + typer.Option("--path", "-p", help="Workspace path (auto-detected if not specified)"), + ] = None, + offline: Annotated[ + bool, + typer.Option("--offline", help="Use bundled config instead of fetching from GitHub"), + ] = False, +) -> None: + """Verify workspace setup and optionally repair issues.""" + workspace_path = path or find_workspace_root() + if workspace_path is None: + console.print("[red]Could not find workspace root. Run from within a workspace or specify --path.[/red]") + raise typer.Exit(1) + + config = load_config(offline=offline) + if config is None: + console.print("[red]Failed to load configuration[/red]") + raise typer.Exit(1) + + check_scope = CheckScope.ALL + if scope: + try: + check_scope = CheckScope(scope.lower()) + except ValueError: + console.print(f"[red]Invalid scope: {scope}. Use: claude, repos, serena, or all[/red]") + raise typer.Exit(1) from None + + console.print(f"[bold]Checking workspace at {workspace_path}...[/bold]") + reports = run_checks(workspace_path, config, check_scope) + + for report in reports: + print_report(report) + + total_errors = sum(r.has_errors for r in reports) + total_warnings = sum(r.has_warnings for r in reports) + total_fixable = sum(r.fixable_count for r in reports) + + console.print() + if total_errors or total_warnings: + parts = [] + if total_errors: + parts.append(f"[red]{total_errors} error(s)[/red]") + if total_warnings: + parts.append(f"[yellow]{total_warnings} warning(s)[/yellow]") + console.print(f"Summary: {', '.join(parts)}") + + if fix and total_fixable: + console.print("\n[bold]Applying fixes...[/bold]") + fixed, failed = apply_fixes(workspace_path, reports) + console.print(f"\nFixed {fixed} issue(s), {failed} failed") + if failed: + raise typer.Exit(1) + elif total_fixable and not fix: + console.print(f"\n[dim]{total_fixable} issue(s) can be fixed with --fix[/dim]") + raise typer.Exit(1) + else: + raise typer.Exit(1) + else: + console.print("[green]All checks passed![/green]") @app.command() -def status() -> None: - """Show workspace status.""" - console.print("[yellow]Not implemented yet[/yellow]") - raise typer.Exit(1) +def sync( + dry_run: Annotated[ + bool, + typer.Option("--dry-run", "-n", help="Show what would be done without making changes"), + ] = 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.""" + workspace_path = path or find_workspace_root() + if workspace_path is None: + console.print("[red]Could not find workspace root. Run from within a workspace or specify --path.[/red]") + raise typer.Exit(1) + + config = load_config() + if config is None: + console.print("[red]Failed to load configuration[/red]") + raise typer.Exit(1) + + console.print("[bold blue]SmartEM Workspace Sync[/bold blue]") + console.print(f"Workspace: {workspace_path}") + + results = sync_all_repos(workspace_path, config, dry_run=dry_run) + print_sync_results(results) + + errors = sum(1 for r in results if r.status == "error") + if errors: + raise typer.Exit(1) + + if dry_run: + would_update = sum(1 for r in results if r.status == "dry-run") + if would_update: + console.print("\n[dim]Run without --dry-run to apply changes[/dim]") + + +@app.command() +def status( + path: Annotated[ + Path | None, + typer.Option("--path", "-p", help="Workspace path"), + ] = None, +) -> None: + """Show workspace status (alias for check --scope all).""" + workspace_path = path or find_workspace_root() + if workspace_path is None: + console.print("[red]Could not find workspace root.[/red]") + raise typer.Exit(1) + + config = load_config() + if config is None: + console.print("[red]Failed to load configuration[/red]") + raise typer.Exit(1) + + console.print(f"[bold]Workspace Status: {workspace_path}[/bold]") + reports = run_checks(workspace_path, config, CheckScope.ALL) + + for report in reports: + print_report(report) @app.command() diff --git a/packages/smartem-workspace/smartem_workspace/commands/__init__.py b/packages/smartem-workspace/smartem_workspace/commands/__init__.py new file mode 100644 index 0000000..6669d99 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/commands/__init__.py @@ -0,0 +1,13 @@ +"""Command implementations for smartem-workspace CLI.""" + +from smartem_workspace.commands.check import CheckReport, CheckResult, CheckScope, run_checks +from smartem_workspace.commands.sync import SyncResult, sync_all_repos + +__all__ = [ + "CheckReport", + "CheckResult", + "CheckScope", + "SyncResult", + "run_checks", + "sync_all_repos", +] diff --git a/packages/smartem-workspace/smartem_workspace/commands/check.py b/packages/smartem-workspace/smartem_workspace/commands/check.py new file mode 100644 index 0000000..5a15350 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/commands/check.py @@ -0,0 +1,279 @@ +"""Workspace verification and repair command.""" + +import json +import os +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Literal + +from rich.console import Console + +from smartem_workspace.config.schema import ReposConfig +from smartem_workspace.setup.repos import get_local_dir + +console = Console() + + +class CheckScope(str, Enum): + ALL = "all" + CLAUDE = "claude" + REPOS = "repos" + SERENA = "serena" + + +@dataclass +class CheckResult: + name: str + status: Literal["ok", "warning", "error"] + message: str + fixable: bool = False + fix_data: dict = field(default_factory=dict) + + +@dataclass +class CheckReport: + scope: str + results: list[CheckResult] + + @property + def has_errors(self) -> bool: + return any(r.status == "error" for r in self.results) + + @property + def has_warnings(self) -> bool: + return any(r.status == "warning" for r in self.results) + + @property + def fixable_count(self) -> int: + return sum(1 for r in self.results if r.fixable) + + +def check_devtools_present(workspace_path: Path) -> CheckResult: + devtools_path = workspace_path / "repos" / "DiamondLightSource" / "smartem-devtools" + if devtools_path.exists() and (devtools_path / ".git").exists(): + return CheckResult("smartem-devtools", "ok", "Present and valid") + if devtools_path.exists(): + return CheckResult("smartem-devtools", "error", "Directory exists but not a git repo") + return CheckResult( + "smartem-devtools", + "error", + "Not cloned (required for configuration)", + fixable=False, + ) + + +def check_symlink(link_path: Path, expected_target: Path, name: str) -> CheckResult: + if not link_path.exists() and not link_path.is_symlink(): + return CheckResult( + name, + "warning", + "Missing", + fixable=True, + fix_data={"link": str(link_path), "target": str(expected_target)}, + ) + + if link_path.is_symlink(): + actual_target = link_path.resolve() + if actual_target == expected_target.resolve(): + return CheckResult(name, "ok", "Valid symlink") + return CheckResult( + name, + "warning", + "Points to wrong target", + fixable=True, + fix_data={"link": str(link_path), "target": str(expected_target)}, + ) + + if link_path.is_file(): + return CheckResult(name, "ok", "Present as file (acceptable)") + + return CheckResult(name, "warning", "Unexpected state", fixable=False) + + +def check_file_exists(file_path: Path, name: str) -> CheckResult: + if file_path.exists(): + return CheckResult(name, "ok", "Present") + return CheckResult(name, "warning", f"Missing: {file_path.name}", fixable=False) + + +def check_json_valid(file_path: Path, name: str) -> CheckResult: + if not file_path.exists(): + return CheckResult(name, "warning", "File missing", fixable=False) + try: + json.loads(file_path.read_text()) + return CheckResult(name, "ok", "Valid JSON") + except json.JSONDecodeError as e: + return CheckResult(name, "error", f"Invalid JSON: {e}") + + +def run_claude_checks(workspace_path: Path, config: ReposConfig) -> CheckReport: + results = [] + devtools_path = workspace_path / "repos" / "DiamondLightSource" / "smartem-devtools" + + results.append(check_devtools_present(workspace_path)) + + claude_config_link = workspace_path / "claude-config" + claude_config_target = devtools_path / "claude-code" + results.append(check_symlink(claude_config_link, claude_config_target, "claude-config symlink")) + + claude_md_link = workspace_path / "CLAUDE.md" + claude_md_target = devtools_path / "claude-code" / "CLAUDE.md" + results.append(check_symlink(claude_md_link, claude_md_target, "CLAUDE.md")) + + skills_dir = workspace_path / ".claude" / "skills" + if not skills_dir.exists(): + results.append( + CheckResult( + ".claude/skills directory", + "warning", + "Missing", + fixable=True, + fix_data={"mkdir": str(skills_dir)}, + ) + ) + else: + results.append(CheckResult(".claude/skills directory", "ok", "Present")) + + for skill in config.claudeConfig.skills: + skill_link = skills_dir / skill.name + skill_target = devtools_path / skill.path + results.append(check_symlink(skill_link, skill_target, f"skill: {skill.name}")) + + settings_path = workspace_path / ".claude" / "settings.local.json" + if settings_path.exists(): + results.append(check_json_valid(settings_path, "settings.local.json")) + else: + results.append(CheckResult("settings.local.json", "warning", "Missing (not auto-fixable)", fixable=False)) + + return CheckReport("claude", results) + + +def run_serena_checks(workspace_path: Path) -> CheckReport: + results = [] + + serena_dir = workspace_path / ".serena" + if serena_dir.exists(): + results.append(CheckResult(".serena directory", "ok", "Present")) + else: + results.append(CheckResult(".serena directory", "warning", "Missing", fixable=False)) + + project_yml = workspace_path / ".serena" / "project.yml" + results.append(check_file_exists(project_yml, ".serena/project.yml")) + + mcp_json = workspace_path / ".mcp.json" + if mcp_json.exists(): + results.append(check_json_valid(mcp_json, ".mcp.json")) + else: + results.append(CheckResult(".mcp.json", "warning", "Missing", fixable=False)) + + return CheckReport("serena", results) + + +def run_repos_checks(workspace_path: Path, config: ReposConfig) -> CheckReport: + results = [] + repos_dir = workspace_path / "repos" + + if not repos_dir.exists(): + results.append(CheckResult("repos directory", "error", "Missing")) + return CheckReport("repos", results) + + results.append(CheckResult("repos directory", "ok", "Present")) + + 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 + full_name = f"{org.name}/{repo.name}" + + if not repo_path.exists(): + results.append(CheckResult(full_name, "warning", "Not cloned", fixable=False)) + continue + + if not (repo_path / ".git").exists(): + results.append(CheckResult(full_name, "error", "Not a git repository")) + continue + + results.append(CheckResult(full_name, "ok", "Cloned")) + + return CheckReport("repos", results) + + +def run_checks( + workspace_path: Path, + config: ReposConfig, + scope: CheckScope = CheckScope.ALL, +) -> list[CheckReport]: + reports = [] + + if scope in (CheckScope.ALL, CheckScope.CLAUDE): + reports.append(run_claude_checks(workspace_path, config)) + + if scope in (CheckScope.ALL, CheckScope.SERENA): + reports.append(run_serena_checks(workspace_path)) + + if scope in (CheckScope.ALL, CheckScope.REPOS): + reports.append(run_repos_checks(workspace_path, config)) + + return reports + + +def apply_fixes(workspace_path: Path, reports: list[CheckReport]) -> tuple[int, int]: + fixed = 0 + failed = 0 + + for report in reports: + for result in report.results: + if not result.fixable or result.status == "ok": + continue + + fix_data = result.fix_data + + if "mkdir" in fix_data: + try: + Path(fix_data["mkdir"]).mkdir(parents=True, exist_ok=True) + console.print(f" [green]Created directory: {fix_data['mkdir']}[/green]") + fixed += 1 + except OSError as e: + console.print(f" [red]Failed to create directory: {e}[/red]") + failed += 1 + + elif "link" in fix_data and "target" in fix_data: + link_path = Path(fix_data["link"]) + target_path = Path(fix_data["target"]) + + try: + if link_path.is_symlink(): + link_path.unlink() + + link_path.parent.mkdir(parents=True, exist_ok=True) + + if target_path.exists(): + os.symlink(str(target_path.resolve()), str(link_path)) + console.print(f" [green]Created symlink: {link_path.name}[/green]") + fixed += 1 + else: + console.print(f" [yellow]Target does not exist: {target_path}[/yellow]") + failed += 1 + except OSError as e: + console.print(f" [red]Failed to create symlink: {e}[/red]") + failed += 1 + + return fixed, failed + + +def print_report(report: CheckReport) -> None: + console.print(f"\n[bold]{report.scope.title()} Configuration:[/bold]") + + for result in report.results: + if result.status == "ok": + icon = "[green]\u2713[/green]" + elif result.status == "warning": + icon = "[yellow]![/yellow]" + else: + icon = "[red]\u2717[/red]" + + fixable_note = " [dim](fixable)[/dim]" if result.fixable and result.status != "ok" else "" + console.print(f" {icon} {result.name}: {result.message}{fixable_note}") diff --git a/packages/smartem-workspace/smartem_workspace/commands/sync.py b/packages/smartem-workspace/smartem_workspace/commands/sync.py new file mode 100644 index 0000000..9138638 --- /dev/null +++ b/packages/smartem-workspace/smartem_workspace/commands/sync.py @@ -0,0 +1,149 @@ +"""Repository synchronization command.""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +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.utils.git import ( + 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"] + message: str + commits_behind: int = 0 + + +def sync_single_repo(repo_path: Path, dry_run: bool = False) -> SyncResult: + repo_name = repo_path.name + org_name = repo_path.parent.name + + if not (repo_path / ".git").exists(): + return SyncResult(repo_name, org_name, "error", "Not a git repository") + + if has_uncommitted_changes(repo_path): + return SyncResult(repo_name, org_name, "skipped", "Has uncommitted changes") + + branch = get_current_branch(repo_path) + if branch and branch not in ("main", "master"): + return SyncResult(repo_name, org_name, "skipped", f"On branch '{branch}', not main/master") + + if not fetch_remote(repo_path): + return SyncResult(repo_name, org_name, "error", "Failed to fetch from remote") + + behind = get_commits_behind(repo_path) + + if behind == 0: + return SyncResult(repo_name, org_name, "up-to-date", "Already up to date") + + if dry_run: + return SyncResult(repo_name, org_name, "dry-run", f"Would pull {behind} commit(s)", commits_behind=behind) + + returncode, stdout, stderr = run_git_command(["pull", "--ff-only"], cwd=repo_path) + + if returncode == 0: + return SyncResult(repo_name, org_name, "updated", f"Pulled {behind} commit(s)", commits_behind=behind) + + if "CONFLICT" in stderr or "diverged" in stderr: + run_git_command(["merge", "--abort"], cwd=repo_path) + return SyncResult(repo_name, org_name, "error", "Merge conflict, aborted") + + return SyncResult(repo_name, org_name, "error", f"Pull failed: {stderr.strip()[:50]}") + + +def sync_all_repos( + workspace_path: Path, + config: ReposConfig, + dry_run: bool = False, +) -> list[SyncResult]: + repos_dir = workspace_path / "repos" + results = [] + + if not repos_dir.exists(): + console.print("[red]repos directory not found[/red]") + return results + + 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]") + return results + + action = "Checking" if dry_run else "Syncing" + console.print(f"\n[bold]{action} {len(repo_paths)} 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)) + + for org_name, repo_name, repo_path in repo_paths: + progress.update(task, description=f"{org_name}/{repo_name}") + result = sync_single_repo(repo_path, dry_run=dry_run) + results.append(result) + progress.advance(task) + + return results + + +def print_sync_results(results: list[SyncResult]) -> None: + 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") + errors = sum(1 for r in results if r.status == "error") + would_update = sum(1 for r in results if r.status == "dry-run") + + for result in results: + full_name = f"{result.org_name}/{result.repo_name}" + + if 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]") + elif result.status == "dry-run": + console.print(f" [cyan]\u2192[/cyan] {full_name}: {result.message}") + elif result.status == "skipped": + console.print(f" [yellow]![/yellow] {full_name}: {result.message}") + else: + console.print(f" [red]\u2717[/red] {full_name}: {result.message}") + + console.print() + parts = [] + if updated: + parts.append(f"[green]{updated} updated[/green]") + if would_update: + parts.append(f"[cyan]{would_update} would update[/cyan]") + if up_to_date: + parts.append(f"{up_to_date} up to date") + if skipped: + parts.append(f"[yellow]{skipped} skipped[/yellow]") + if errors: + parts.append(f"[red]{errors} errors[/red]") + + console.print(f"Summary: {', '.join(parts)}") diff --git a/packages/smartem-workspace/smartem_workspace/config/loader.py b/packages/smartem-workspace/smartem_workspace/config/loader.py index 07d6dfc..9364247 100644 --- a/packages/smartem-workspace/smartem_workspace/config/loader.py +++ b/packages/smartem-workspace/smartem_workspace/config/loader.py @@ -52,14 +52,19 @@ def load_from_file(path: Path) -> dict | None: return None -def load_config(local_path: Path | None = None) -> ReposConfig | None: +def load_config(local_path: Path | None = None, offline: bool = False) -> ReposConfig | None: """ Load workspace configuration. Strategy: 1. If local_path provided, use that - 2. Try network (GitHub raw) - 3. Fall back to bundled config + 2. If offline, use bundled config + 3. Try network (GitHub raw) + 4. Fall back to bundled config + + Args: + local_path: Path to local config file + offline: Skip network fetch, use bundled config Returns: ReposConfig if successful, None otherwise @@ -69,6 +74,9 @@ def load_config(local_path: Path | None = None) -> ReposConfig | None: if local_path: console.print(f"[dim]Loading config from: {local_path}[/dim]") config_dict = load_from_file(local_path) + elif offline: + console.print("[dim]Using bundled config (offline mode)[/dim]") + config_dict = load_from_bundled() else: console.print("[dim]Fetching latest config from GitHub...[/dim]") config_dict = load_from_network() diff --git a/packages/smartem-workspace/smartem_workspace/utils/git.py b/packages/smartem-workspace/smartem_workspace/utils/git.py index ddb5ce6..a0e6b1d 100644 --- a/packages/smartem-workspace/smartem_workspace/utils/git.py +++ b/packages/smartem-workspace/smartem_workspace/utils/git.py @@ -68,3 +68,75 @@ def has_uncommitted_changes(repo_path: Path) -> bool: if returncode == 0: return bool(stdout.strip()) return False + + +def fetch_remote(repo_path: Path, remote: str = "origin") -> bool: + """ + Fetch latest changes from remote. + + Returns: + True if successful + """ + returncode, _, _ = run_git_command( + ["fetch", remote], + cwd=repo_path, + timeout=60, + ) + return returncode == 0 + + +def get_commits_behind(repo_path: Path, remote: str = "origin") -> int: + """ + Get the number of commits the local branch is behind the remote. + + Returns: + Number of commits behind, or 0 if up to date or on error + """ + branch = get_current_branch(repo_path) + if not branch: + return 0 + + returncode, stdout, _ = run_git_command( + ["rev-list", "--count", f"HEAD..{remote}/{branch}"], + cwd=repo_path, + ) + if returncode == 0: + try: + return int(stdout.strip()) + except ValueError: + return 0 + return 0 + + +def get_commits_ahead(repo_path: Path, remote: str = "origin") -> int: + """ + Get the number of commits the local branch is ahead of the remote. + + Returns: + Number of commits ahead, or 0 if up to date or on error + """ + branch = get_current_branch(repo_path) + if not branch: + return 0 + + returncode, stdout, _ = run_git_command( + ["rev-list", "--count", f"{remote}/{branch}..HEAD"], + cwd=repo_path, + ) + if returncode == 0: + try: + return int(stdout.strip()) + except ValueError: + return 0 + return 0 + + +def has_remote(repo_path: Path, remote: str = "origin") -> bool: + """Check if a repository has the specified remote configured.""" + returncode, stdout, _ = run_git_command( + ["remote"], + cwd=repo_path, + ) + if returncode == 0: + return remote in stdout.split() + return False From a20df3b5e47a75ac72b0b6e850b06543affa69b6 Mon Sep 17 00:00:00 2001 From: Val Redchenko Date: Wed, 14 Jan 2026 19:03:26 +0000 Subject: [PATCH 4/4] chore: release smartem-workspace v0.2.0 --- packages/smartem-workspace/README.md | 48 ++++++++++++++++++++--- packages/smartem-workspace/pyproject.toml | 2 +- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/smartem-workspace/README.md b/packages/smartem-workspace/README.md index 7ce5b42..0073f95 100644 --- a/packages/smartem-workspace/README.md +++ b/packages/smartem-workspace/README.md @@ -44,20 +44,44 @@ smartem-workspace init --preset full --no-interactive | `aria-reference` | ARIA ecosystem repos for reference | | `minimal` | Just smartem-devtools (workspace setup only) | -### Other commands +### Verify workspace setup ```bash -# Sync existing repos (git pull) +# Check all configuration +smartem-workspace check + +# Check specific scope +smartem-workspace check --scope claude +smartem-workspace check --scope repos +smartem-workspace check --scope serena + +# Auto-repair fixable issues (broken symlinks, missing dirs) +smartem-workspace check --fix +``` + +### Sync repositories + +```bash +# Pull latest from all cloned repos smartem-workspace sync -# Show workspace status +# Preview what would be pulled +smartem-workspace sync --dry-run +``` + +Sync skips repos with uncommitted changes or not on main/master branch. + +### Other commands + +```bash +# Show workspace status (alias for check) smartem-workspace status -# Add a single repo +# Add a single repo (not yet implemented) smartem-workspace add DiamondLightSource/smartem-frontend ``` -### Options +### Init options ``` --path PATH Target directory (default: current directory) @@ -68,6 +92,20 @@ smartem-workspace add DiamondLightSource/smartem-frontend --skip-serena Skip Serena MCP setup ``` +### Check options + +``` +--scope SCOPE Check scope: claude, repos, serena, or all (default: all) +--fix Attempt to fix issues (recreate symlinks, dirs) +--offline Use bundled config instead of fetching from GitHub +``` + +### Sync options + +``` +--dry-run, -n Show what would be done without making changes +``` + ## What it sets up 1. **Repository clones** - Organized by organization (DiamondLightSource, FragmentScreen, GitlabAriaPHP) diff --git a/packages/smartem-workspace/pyproject.toml b/packages/smartem-workspace/pyproject.toml index 08b2726..3b6d549 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.1.0" +version = "0.2.0" description = "CLI tool to automate SmartEM multi-repo workspace setup" readme = "README.md" license = "Apache-2.0"