diff --git a/src/wcheck/wcheck.py b/src/wcheck/wcheck.py index d188a6c..7e13263 100644 --- a/src/wcheck/wcheck.py +++ b/src/wcheck/wcheck.py @@ -600,6 +600,70 @@ def check_workspace_status( show_repos_config_versions(workspace_current_branch_version, full=True) +def compare_workspaces( + workspace_directories: tuple[Path, ...], + full: bool = False, + verbose: bool = False, + show_time: bool = False, + fetch: bool = False, +) -> None: + """Compare repository statuses across multiple workspaces. + + Displays a side-by-side table showing the branch and status of each + repository across different workspaces. Useful for comparing the same + set of repositories in different environments or configurations. + + Args: + workspace_directories: Tuple of workspace directory paths to compare. + full: If True, show all repositories. If False, only show those with + differences or changes. + verbose: If True, print additional information about each repository. + show_time: If True, include time since last commit in the output. + fetch: If True, fetch from remotes before checking status. + """ + repos_by_workspace: dict[str, dict[str, str]] = {} + + # Use basename for column names, with disambiguation for duplicates + workspace_names: list[str] = [] + for ws_path in workspace_directories: + name = ws_path.name + # If duplicate basename, use parent/name + count = sum(1 for w in workspace_directories if w.name == name) + if count > 1: + name = f"{ws_path.parent.name}/{name}" + workspace_names.append(name) + + # Collect repos from each workspace + for i, workspace_directory in enumerate(workspace_directories): + ws_name = workspace_names[i] + click.echo(f"Scanning workspace: {workspace_directory}") + source_repos = get_workspace_repos(workspace_directory) + + if fetch: + for repo_name in source_repos: + for remote in source_repos[repo_name].remotes: + remote.fetch() + + repos_by_workspace[ws_name] = {} + for repo_name in source_repos: + repo = source_repos[repo_name] + branch = get_repo_head_ref(repo, verbose) + status_str = get_status_repo(repo) + + # Format: "branch (status)" or just "branch" if clean + if status_str: + display_value = f"{branch}{status_str}" + else: + display_value = branch + + if show_time: + display_value += f" ({get_elapsed_time_repo(repo)})" + + repos_by_workspace[ws_name][repo_name] = display_value + + show_repos_config_versions(repos_by_workspace, full) + + def compare_workspace_to_config( workspace_directory: Path, config_filename: str, @@ -671,9 +735,10 @@ def cli(): @click.option( "-w", "--workspace-directory", + "workspace_directories", type=click.Path(exists=True, file_okay=False, path_type=Path), - default=None, - help="Workspace directory. Use current directory if not specified", + multiple=True, + help="Workspace directory. Can be specified multiple times to compare workspaces. Uses current directory if not specified.", ) @click.option( "-f", @@ -685,20 +750,43 @@ def cli(): @click.option("--show-time", is_flag=True, help="Show last modified time") @click.option("--fetch", is_flag=True, help="Fetch remote branches") @click.option("--gui", is_flag=True, help="Use GUI to change branches") -def status(workspace_directory, full, verbose, show_time, fetch, gui): - """Check the status of all repositories in a workspace.""" - if not workspace_directory: +def status(workspace_directories, full, verbose, show_time, fetch, gui): + """Check the status of all repositories in a workspace. + + When a single workspace is specified (or none, using current directory), + shows the status of each repository. + + When multiple workspaces are specified with multiple -w options, + displays a side-by-side comparison table. + """ + if not workspace_directories: click.echo("Workspace directory is not specified, using current directory") - workspace_directory = Path(os.getcwd()) - click.echo(f"Using workspace directory {workspace_directory}") - check_workspace_status( - workspace_directory, - full, - verbose, - show_time, - fetch=fetch, - gui=gui, - ) + workspace_directories = (Path(os.getcwd()),) + + if len(workspace_directories) == 1: + # Single workspace: use original behavior + click.echo(f"Using workspace directory {workspace_directories[0]}") + check_workspace_status( + workspace_directories[0], + full, + verbose, + show_time, + fetch=fetch, + gui=gui, + ) + else: + # Multiple workspaces: compare side by side + if gui: + click.echo("GUI mode is not supported for multi-workspace comparison") + return + click.echo(f"Comparing {len(workspace_directories)} workspaces") + compare_workspaces( + workspace_directories, + full, + verbose, + show_time, + fetch=fetch, + ) @cli.command() diff --git a/tests/test_status.py b/tests/test_status.py index f9d111c..8bb908e 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,6 +1,7 @@ """Tests for the status command.""" from click.testing import CliRunner +from git import Repo from wcheck.wcheck import cli, get_workspace_repos @@ -98,3 +99,114 @@ def test_get_workspace_repos_nonexistent(self, tmp_path): nonexistent = tmp_path / "nonexistent" found_repos = get_workspace_repos(nonexistent) assert len(found_repos) == 0 + + +class TestMultiWorkspaceComparison: + """Tests for comparing multiple workspaces side by side.""" + + def test_status_multiple_workspaces(self, tmp_path): + """Test status command with multiple workspaces.""" + # Create two separate workspaces + workspace1 = tmp_path / "workspace1" + workspace2 = tmp_path / "workspace2" + workspace1.mkdir() + workspace2.mkdir() + + # Create repos in workspace1 + for repo_name in ["repo_a", "repo_b"]: + repo_path = workspace1 / repo_name + repo_path.mkdir() + repo = Repo.init(repo_path) + repo.config_writer().set_value("user", "name", "Test User").release() + repo.config_writer().set_value("user", "email", "test@test.com").release() + readme = repo_path / "README.md" + readme.write_text(f"# {repo_name}\n") + repo.index.add(["README.md"]) + repo.index.commit("Initial commit") + + # Create repos in workspace2 (with different branches) + for repo_name in ["repo_a", "repo_c"]: + repo_path = workspace2 / repo_name + repo_path.mkdir() + repo = Repo.init(repo_path) + repo.config_writer().set_value("user", "name", "Test User").release() + repo.config_writer().set_value("user", "email", "test@test.com").release() + readme = repo_path / "README.md" + readme.write_text(f"# {repo_name}\n") + repo.index.add(["README.md"]) + repo.index.commit("Initial commit") + if repo_name == "repo_a": + repo.create_head("develop") + repo.heads.develop.checkout() + + runner = CliRunner() + result = runner.invoke( + cli, + ["status", "-w", str(workspace1), "-w", str(workspace2), "--full"], + ) + assert result.exit_code == 0 + assert "Comparing 2 workspaces" in result.output + assert "workspace1" in result.output + assert "workspace2" in result.output + + def test_status_multiple_workspaces_shows_differences(self, tmp_path): + """Test that multi-workspace comparison highlights differences.""" + workspace1 = tmp_path / "ws1" + workspace2 = tmp_path / "ws2" + workspace1.mkdir() + workspace2.mkdir() + + # Create same repo in both, but different branches + for ws, branch in [(workspace1, "main"), (workspace2, "develop")]: + repo_path = ws / "shared_repo" + repo_path.mkdir() + repo = Repo.init(repo_path) + repo.config_writer().set_value("user", "name", "Test User").release() + repo.config_writer().set_value("user", "email", "test@test.com").release() + readme = repo_path / "README.md" + readme.write_text("# shared_repo\n") + repo.index.add(["README.md"]) + repo.index.commit("Initial commit") + if branch != "main": + repo.create_head(branch) + repo.heads[branch].checkout() + + runner = CliRunner() + result = runner.invoke( + cli, + ["status", "-w", str(workspace1), "-w", str(workspace2)], + ) + assert result.exit_code == 0 + # Both should appear since branches differ + assert "shared_repo" in result.output + + def test_status_multiple_workspaces_gui_not_supported(self, tmp_path): + """Test that GUI mode is not supported for multi-workspace comparison.""" + workspace1 = tmp_path / "ws1" + workspace2 = tmp_path / "ws2" + workspace1.mkdir() + workspace2.mkdir() + + runner = CliRunner() + result = runner.invoke( + cli, + ["status", "-w", str(workspace1), "-w", str(workspace2), "--gui"], + ) + assert "GUI mode is not supported for multi-workspace comparison" in result.output + + def test_status_single_workspace_unchanged_behavior(self, temp_workspace): + """Test that single workspace behavior is unchanged.""" + workspace, repos = temp_workspace + runner = CliRunner() + result = runner.invoke(cli, ["status", "-w", str(workspace)]) + assert result.exit_code == 0 + assert f"Using workspace directory {workspace}" in result.output + # Should NOT say "Comparing X workspaces" + assert "Comparing" not in result.output + + def test_status_help_shows_multiple_option(self): + """Test that help text mentions multiple workspaces.""" + runner = CliRunner() + result = runner.invoke(cli, ["status", "--help"]) + assert result.exit_code == 0 + assert "multiple" in result.output.lower() or "compare" in result.output.lower()