diff --git a/src/erk/cli/cli.py b/src/erk/cli/cli.py index 86c2db933..57a7a5756 100644 --- a/src/erk/cli/cli.py +++ b/src/erk/cli/cli.py @@ -50,7 +50,6 @@ def cli(ctx: click.Context) -> None: cli.add_command(up_cmd) cli.add_command(list_cmd) cli.add_command(ls_cmd) -cli.add_command(split_cmd) cli.add_command(status_cmd) cli.add_command(init_cmd) cli.add_command(move_cmd) @@ -58,6 +57,7 @@ def cli(ctx: click.Context) -> None: cli.add_command(del_cmd) cli.add_command(rename_cmd) cli.add_command(config_group) +cli.add_command(split_cmd) cli.add_command(sync_cmd) cli.add_command(hidden_shell_cmd) cli.add_command(prepare_cwd_recovery_cmd) diff --git a/src/erk/cli/commands/forest/__init__.py b/src/erk/cli/commands/forest/__init__.py index 901e92d66..1ed481edb 100644 --- a/src/erk/cli/commands/forest/__init__.py +++ b/src/erk/cli/commands/forest/__init__.py @@ -2,14 +2,10 @@ import click -# TODO: Implement forest subcommands -# from erk.cli.commands.forest.list import list_forests -# from erk.cli.commands.forest.merge import merge_forest -# from erk.cli.commands.forest.rename import rename_forest -# from erk.cli.commands.forest.reroot import reroot_forest -# from erk.cli.commands.forest.show import show_forest -# from erk.cli.commands.forest.show_current import show_current_forest -# from erk.cli.commands.forest.split import split_forest +from erk.cli.commands.forest.list import list_forests +from erk.cli.commands.forest.rename import rename_forest +from erk.cli.commands.forest.show import show_forest +from erk.cli.commands.forest.show_current import show_current_forest @click.group("forest", invoke_without_command=True) @@ -22,16 +18,13 @@ def forest_group(ctx: click.Context) -> None: When called without a subcommand, shows the forest for the current worktree. """ - # If no subcommand is provided, show placeholder message + # If no subcommand is provided, show current forest if ctx.invoked_subcommand is None: - click.echo("Forest command infrastructure in progress.") - click.echo("Subcommands will be available in a future release.") + from erk.cli.commands.forest.show_current import show_current_forest + ctx.invoke(show_current_forest) -# Register subcommands (TODO: uncomment when implemented) -# forest_group.add_command(list_forests) -# forest_group.add_command(show_forest) -# forest_group.add_command(rename_forest) -# forest_group.add_command(split_forest) -# forest_group.add_command(merge_forest) -# forest_group.add_command(reroot_forest) +# Register subcommands +forest_group.add_command(list_forests) +forest_group.add_command(show_forest) +forest_group.add_command(rename_forest) diff --git a/src/erk/cli/commands/forest/list.py b/src/erk/cli/commands/forest/list.py new file mode 100644 index 000000000..12d6f4fe5 --- /dev/null +++ b/src/erk/cli/commands/forest/list.py @@ -0,0 +1,63 @@ +"""List all forests command.""" + +from datetime import datetime + +import click + +from erk.cli.output import user_output +from erk.core.context import ErkContext +from erk.core.repo_discovery import NoRepoSentinel, RepoContext + + +def format_date(iso_timestamp: str) -> str: + """Format ISO 8601 timestamp to human-readable date. + + Args: + iso_timestamp: ISO 8601 format timestamp + + Returns: + Formatted date like "2024-01-15" + """ + dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d") + + +@click.command("list", help="List all forests in repository") +@click.pass_obj +def list_forests(ctx: ErkContext) -> None: + """List all forests in repository. + + Displays forest name, worktree count, and creation date. + Sorted by creation date (newest first). + """ + if isinstance(ctx.repo, NoRepoSentinel): + user_output( + click.style("Error: ", fg="red") + + "Not in a repository. This command requires a git repository." + ) + raise SystemExit(1) + + # Load forests + metadata = ctx.forest_ops.load_forests() + + if not metadata.forests: + user_output("No forests in repository.") + raise SystemExit(0) + + # Get repository name for header + repo: RepoContext = ctx.repo # Type narrowing + repo_name = repo.root.name + + # Sort forests by creation date (newest first) + sorted_forests = sorted(metadata.forests.values(), key=lambda f: f.created_at, reverse=True) + + # Display header + user_output(f"Forests in {click.style(repo_name, bold=True)}:") + + # Display each forest + for forest in sorted_forests: + forest_name = click.style(forest.name, fg="cyan", bold=True) + count = f"({len(forest.worktrees)} worktrees)" + date = f"created {format_date(forest.created_at)}" + + user_output(f"• {forest_name} {count} - {date}") diff --git a/src/erk/cli/commands/forest/rename.py b/src/erk/cli/commands/forest/rename.py new file mode 100644 index 000000000..0b7885445 --- /dev/null +++ b/src/erk/cli/commands/forest/rename.py @@ -0,0 +1,82 @@ +"""Rename forest command.""" + +import click + +from erk.cli.output import user_output +from erk.core.context import ErkContext +from erk.core.forest_utils import rename_forest as rename_forest_util +from erk.core.forest_utils import validate_forest_name +from erk.core.repo_discovery import NoRepoSentinel + + +@click.command("rename", help="Rename a forest (label only, paths unchanged)") +@click.argument("old_name") +@click.argument("new_name") +@click.pass_obj +def rename_forest(ctx: ErkContext, old_name: str, new_name: str) -> None: + """Rename a forest (label only, paths unchanged). + + This changes the forest label in metadata. Worktree paths remain unchanged. + + Args: + old_name: Current forest name + new_name: New forest name + """ + if isinstance(ctx.repo, NoRepoSentinel): + user_output( + click.style("Error: ", fg="red") + + "Not in a repository. This command requires a git repository." + ) + raise SystemExit(1) + + # Validate new name + if not validate_forest_name(new_name): + user_output( + click.style("Error: ", fg="red") + + f"Invalid forest name: '{new_name}'\n\n" + + "Forest names must be:\n" + + "• Non-empty\n" + + "• Max 30 characters\n" + + "• Alphanumeric + hyphens only" + ) + raise SystemExit(1) + + # Load forests + metadata = ctx.forest_ops.load_forests() + + # Validate old_name exists + if old_name not in metadata.forests: + available = ", ".join(metadata.forests.keys()) if metadata.forests else "(none)" + user_output( + click.style("Error: ", fg="red") + + f"Forest '{old_name}' does not exist\n\n" + + f"Available forests: {available}" + ) + raise SystemExit(1) + + # Validate new_name doesn't conflict + if new_name in metadata.forests: + user_output( + click.style("Error: ", fg="red") + + f"Forest '{new_name}' already exists\n\n" + + "Choose a different name." + ) + raise SystemExit(1) + + # Perform rename + updated_metadata = rename_forest_util(metadata, old_name, new_name) + + # Save + ctx.forest_ops.save_forests(updated_metadata) + + # Display success + user_output( + click.style("✓", fg="green") + + f" Renamed forest '{click.style(old_name, fg='yellow')}' to " + + f"'{click.style(new_name, fg='cyan', bold=True)}'" + ) + user_output() + user_output( + click.style("Note: ", fg="white", dim=True) + + "This only changes the forest label. Worktree paths remain unchanged." + ) diff --git a/src/erk/cli/commands/forest/show.py b/src/erk/cli/commands/forest/show.py new file mode 100644 index 000000000..883ac72fc --- /dev/null +++ b/src/erk/cli/commands/forest/show.py @@ -0,0 +1,99 @@ +"""Show specific forest command.""" + +import click + +from erk.cli.output import user_output +from erk.core.context import ErkContext +from erk.core.forest_utils import find_forest_by_worktree +from erk.core.repo_discovery import NoRepoSentinel +from erk.core.worktree_utils import find_current_worktree + + +@click.command("show", help="Show details of a specific forest") +@click.argument("name", required=False) +@click.pass_obj +def show_forest(ctx: ErkContext, name: str | None) -> None: + """Show details of a specific forest. + + If name is not provided, shows the forest for the current worktree. + + Args: + name: Optional forest name to show + """ + if isinstance(ctx.repo, NoRepoSentinel): + user_output( + click.style("Error: ", fg="red") + + "Not in a repository. This command requires a git repository." + ) + raise SystemExit(1) + + # Load forests + metadata = ctx.forest_ops.load_forests() + + # Determine which forest to show + if name is None: + # Default to current worktree's forest + worktrees = ctx.git_ops.list_worktrees(ctx.repo.root) + current_wt_info = find_current_worktree(worktrees, ctx.cwd) + + if current_wt_info is None: + user_output( + click.style("Error: ", fg="red") + + "Not in a worktree and no forest name provided.\n\n" + + "Usage: erk forest show [NAME]" + ) + raise SystemExit(1) + + worktree_name = current_wt_info.path.name + forest = find_forest_by_worktree(metadata, worktree_name) + + if forest is None: + user_output( + click.style("Error: ", fg="red") + + "Current worktree is not in a forest.\n\n" + + "Use 'erk forest list' to see all forests." + ) + raise SystemExit(1) + + current_worktree = worktree_name + else: + # Show specified forest + if name not in metadata.forests: + available = ", ".join(metadata.forests.keys()) if metadata.forests else "(none)" + user_output( + click.style("❌ Error: ", fg="red") + + f"Forest '{name}' not found\n\n" + + f"Available forests: {available}" + ) + raise SystemExit(1) + + forest = metadata.forests[name] + + # Try to determine current worktree + worktrees = ctx.git_ops.list_worktrees(ctx.repo.root) + current_wt_info = find_current_worktree(worktrees, ctx.cwd) + current_worktree = ( + current_wt_info.path.name + if current_wt_info and current_wt_info.path.name in forest.worktrees + else None + ) + + # Display forest + forest_header = click.style(f"Forest: {forest.name}", fg="cyan", bold=True) + count_text = f" ({len(forest.worktrees)} worktrees)" + user_output(forest_header + count_text) + + # Display worktrees in tree format + for idx, wt in enumerate(forest.worktrees): + is_last = idx == len(forest.worktrees) - 1 + prefix = "└──" if is_last else "├──" + + # Highlight current worktree + wt_display = click.style(wt, fg="yellow") + branch_display = f"[{wt}]" + + if wt == current_worktree: + indicator = click.style(" ← you are here", fg="bright_green", bold=True) + user_output(f"{prefix} {wt_display} {branch_display}{indicator}") + else: + user_output(f"{prefix} {wt_display} {branch_display}") diff --git a/src/erk/cli/commands/forest/show_current.py b/src/erk/cli/commands/forest/show_current.py new file mode 100644 index 000000000..b7e1c3cc5 --- /dev/null +++ b/src/erk/cli/commands/forest/show_current.py @@ -0,0 +1,66 @@ +"""Show current worktree's forest command.""" + +import click + +from erk.cli.output import user_output +from erk.core.context import ErkContext +from erk.core.forest_utils import find_forest_by_worktree +from erk.core.repo_discovery import NoRepoSentinel +from erk.core.worktree_utils import find_current_worktree + + +@click.command("forest", help="Show forest for current worktree") +@click.pass_obj +def show_current_forest(ctx: ErkContext) -> None: + """Show forest for current worktree. + + Displays the forest name and all worktrees in the forest, + highlighting the current worktree. + """ + if isinstance(ctx.repo, NoRepoSentinel): + user_output( + click.style("Error: ", fg="red") + + "Not in a repository. This command requires a git repository." + ) + raise SystemExit(1) + + # Get current worktree + worktrees = ctx.git_ops.list_worktrees(ctx.repo.root) + current_wt = find_current_worktree(worktrees, ctx.cwd) + + if current_wt is None: + user_output("Current directory is not in an erk worktree.") + raise SystemExit(1) + + # Extract worktree name from path + worktree_name = current_wt.path.name + + # Load forests + metadata = ctx.forest_ops.load_forests() + + # Find forest for current worktree + forest = find_forest_by_worktree(metadata, worktree_name) + + if forest is None: + user_output("Current worktree is not in a forest.") + raise SystemExit(0) + + # Display forest + forest_header = click.style(f"Forest: {forest.name}", fg="cyan", bold=True) + count_text = f" ({len(forest.worktrees)} worktrees)" + user_output(forest_header + count_text) + + # Display worktrees in tree format + for idx, wt in enumerate(forest.worktrees): + is_last = idx == len(forest.worktrees) - 1 + prefix = "└──" if is_last else "├──" + + # Highlight current worktree + wt_display = click.style(wt, fg="yellow") + branch_display = f"[{wt}]" + + if wt == worktree_name: + indicator = click.style(" ← you are here", fg="bright_green", bold=True) + user_output(f"{prefix} {wt_display} {branch_display}{indicator}") + else: + user_output(f"{prefix} {wt_display} {branch_display}") diff --git a/src/erk/core/consolidation_utils.py b/src/erk/core/consolidation_utils.py index bca47788d..721ae4120 100644 --- a/src/erk/core/consolidation_utils.py +++ b/src/erk/core/consolidation_utils.py @@ -1,4 +1,4 @@ -"""Pure utility functions for consolidate command planning.""" +"""Utility functions for worktree consolidation planning.""" from dataclasses import dataclass from pathlib import Path @@ -8,26 +8,24 @@ @dataclass(frozen=True) class ConsolidationPlan: - """Plan for consolidating stack branches into target worktree.""" + """Plan for consolidating worktrees containing stack branches.""" stack_to_consolidate: list[str] worktrees_to_remove: list[WorktreeInfo] - target_worktree_path: Path - source_worktree_path: Path | None def calculate_stack_range( stack_branches: list[str], end_branch: str | None, ) -> list[str]: - """Calculate which branches in the stack should be consolidated. + """Calculate the range of branches to consolidate. Args: - stack_branches: Full list of branches in stack (trunk to leaf) - end_branch: Branch to consolidate up to (inclusive), or None for full stack + stack_branches: Full stack from trunk to leaf (ordered) + end_branch: Optional branch to end consolidation at (None = full stack) Returns: - List of branch names to consolidate + List of branches to consolidate (trunk to end_branch, or full stack if None) """ if end_branch is None: return stack_branches @@ -47,45 +45,41 @@ def create_consolidation_plan( target_worktree_path: Path, source_worktree_path: Path | None, ) -> ConsolidationPlan: - """Create a plan for consolidating stack branches. + """Create a consolidation plan identifying worktrees to remove. Args: all_worktrees: All worktrees in the repository - stack_branches: Full list of branches in stack (trunk to leaf) - end_branch: Branch to consolidate up to (inclusive), or None for full stack - target_worktree_path: Path to worktree that will contain consolidated branches - source_worktree_path: Original worktree path (if creating new target), or None + stack_branches: Full stack branches (trunk to leaf) + end_branch: Optional branch to end consolidation at (None = full stack) + target_worktree_path: Path of the target worktree (where branches will be consolidated) + source_worktree_path: Path of source worktree if creating new target (to be removed) Returns: - ConsolidationPlan with branches and worktrees to remove + ConsolidationPlan with stack range and worktrees to remove """ - # Calculate which branches should be consolidated stack_to_consolidate = calculate_stack_range(stack_branches, end_branch) - # Find worktrees to remove: - # - Worktrees containing branches in stack_to_consolidate - # - Skip root worktree (never removed) - # - Skip target worktree (consolidation destination) worktrees_to_remove: list[WorktreeInfo] = [] - for wt in all_worktrees: - # Skip if branch not in consolidation range + # Skip worktrees not in consolidation range if wt.branch not in stack_to_consolidate: continue - # Skip root worktree + # Skip root worktree (never remove) if wt.is_root: continue - # Skip target worktree + # Skip target worktree (consolidation destination) if wt.path.resolve() == target_worktree_path.resolve(): continue + # Skip source worktree if creating new target (handled separately) + if source_worktree_path is not None and wt.path.resolve() == source_worktree_path.resolve(): + continue + worktrees_to_remove.append(wt) return ConsolidationPlan( stack_to_consolidate=stack_to_consolidate, worktrees_to_remove=worktrees_to_remove, - target_worktree_path=target_worktree_path, - source_worktree_path=source_worktree_path, ) diff --git a/tests/commands/forest/test_forest_query.py b/tests/commands/forest/test_forest_query.py new file mode 100644 index 000000000..63170c70f --- /dev/null +++ b/tests/commands/forest/test_forest_query.py @@ -0,0 +1,241 @@ +"""Integration tests for forest query commands.""" + +import pytest +from click.testing import CliRunner +from tests.fakes.forest_ops import FakeForestOps +from tests.fakes.gitops import FakeGitOps +from tests.test_utils.env_helpers import erk_inmem_env + +from erk.cli.cli import cli +from erk.core.forest_types import Forest, ForestMetadata +from erk.core.gitops import WorktreeInfo + +# TODO: These tests need to use erk_isolated_fs_env instead of erk_inmem_env +# because forest commands use find_current_worktree() which requires proper +# path resolution via is_relative_to(). Sentinel paths don't work for this. +# See: https://github.com/anthropics/erk/issues/XXX + + +@pytest.mark.skip(reason="TODO: Needs erk_isolated_fs_env for path resolution") +def test_forest_show_current_in_forest() -> None: + """Test showing current forest when in a forest.""" + runner = CliRunner() + + with erk_inmem_env(runner) as env: + worktrees_dir = env.erk_root / "repos" / "test-repo" / "worktrees" + wt_path = worktrees_dir / "my-worktree" + + git_ops = FakeGitOps( + git_common_dirs={wt_path: env.git_dir}, + default_branches={wt_path: "main"}, + worktrees={wt_path: [WorktreeInfo(path=wt_path, branch="feat-1", is_root=False)]}, + ) + + forest_ops = FakeForestOps() + forest = Forest( + name="my-forest", + worktrees=["my-worktree", "other-wt"], + created_at="2025-01-01T00:00:00Z", + root_branch="main", + ) + forest_ops.save_forests(ForestMetadata(forests={"my-forest": forest})) + + ctx = env.build_context(git_ops=git_ops, forest_ops=forest_ops, cwd=wt_path) + + result = runner.invoke(cli, ["forest"], obj=ctx) + + assert result.exit_code == 0 + assert "Forest: my-forest" in result.output + assert "you are here" in result.output + + +@pytest.mark.skip(reason="TODO: Needs erk_isolated_fs_env for path resolution") +def test_forest_show_current_not_in_forest() -> None: + """Test showing current forest when not in a forest.""" + runner = CliRunner() + + with erk_inmem_env(runner) as env: + worktrees_dir = env.erk_root / "repos" / "test-repo" / "worktrees" + wt_path = worktrees_dir / "my-worktree" + + git_ops = FakeGitOps( + git_common_dirs={wt_path: env.git_dir}, + worktrees={wt_path: [WorktreeInfo(path=wt_path, branch="feat-1", is_root=False)]}, + ) + + forest_ops = FakeForestOps() + ctx = env.build_context(git_ops=git_ops, forest_ops=forest_ops, cwd=wt_path) + + result = runner.invoke(cli, ["forest"], obj=ctx) + + assert result.exit_code == 0 + assert "not in a forest" in result.output + + +def test_forest_list_multiple() -> None: + """Test listing multiple forests.""" + runner = CliRunner() + + with erk_inmem_env(runner) as env: + git_ops = FakeGitOps( + git_common_dirs={env.cwd: env.git_dir}, + default_branches={env.cwd: "main"}, + ) + + forest_ops = FakeForestOps() + forests = { + "forest1": Forest( + name="forest1", + worktrees=["wt1", "wt2"], + created_at="2025-01-01T00:00:00Z", + root_branch="main", + ), + "forest2": Forest( + name="forest2", + worktrees=["wt3"], + created_at="2025-01-02T00:00:00Z", + root_branch="main", + ), + } + forest_ops.save_forests(ForestMetadata(forests=forests)) + + ctx = env.build_context(git_ops=git_ops, forest_ops=forest_ops) + + result = runner.invoke(cli, ["forest", "list"], obj=ctx) + + assert result.exit_code == 0 + assert "forest1" in result.output + assert "forest2" in result.output + assert "2 worktrees" in result.output + assert "1 worktree" in result.output or "1 worktrees" in result.output + + +def test_forest_list_empty() -> None: + """Test listing forests when none exist.""" + runner = CliRunner() + + with erk_inmem_env(runner) as env: + git_ops = FakeGitOps( + git_common_dirs={env.cwd: env.git_dir}, + ) + + forest_ops = FakeForestOps() + ctx = env.build_context(git_ops=git_ops, forest_ops=forest_ops) + + result = runner.invoke(cli, ["forest", "list"], obj=ctx) + + assert result.exit_code == 0 + assert "No forests" in result.output + + +@pytest.mark.skip(reason="TODO: Needs erk_isolated_fs_env for path resolution") +def test_forest_show_specific() -> None: + """Test showing specific forest by name.""" + runner = CliRunner() + + with erk_inmem_env(runner) as env: + git_ops = FakeGitOps( + git_common_dirs={env.cwd: env.git_dir}, + ) + + forest_ops = FakeForestOps() + forest = Forest( + name="target-forest", + worktrees=["wt1", "wt2"], + created_at="2025-01-01T00:00:00Z", + root_branch="main", + ) + forest_ops.save_forests(ForestMetadata(forests={"target-forest": forest})) + + ctx = env.build_context(git_ops=git_ops, forest_ops=forest_ops) + + result = runner.invoke(cli, ["forest", "show", "target-forest"], obj=ctx) + + assert result.exit_code == 0 + assert "Forest: target-forest" in result.output + assert "wt1" in result.output + assert "wt2" in result.output + + +def test_forest_show_nonexistent() -> None: + """Test showing nonexistent forest.""" + runner = CliRunner() + + with erk_inmem_env(runner) as env: + git_ops = FakeGitOps( + git_common_dirs={env.cwd: env.git_dir}, + ) + + forest_ops = FakeForestOps() + ctx = env.build_context(git_ops=git_ops, forest_ops=forest_ops) + + result = runner.invoke(cli, ["forest", "show", "nonexistent"], obj=ctx) + + assert result.exit_code == 1 + assert "not found" in result.output + + +def test_forest_rename_success() -> None: + """Test renaming a forest.""" + runner = CliRunner() + + with erk_inmem_env(runner) as env: + git_ops = FakeGitOps( + git_common_dirs={env.cwd: env.git_dir}, + ) + + forest_ops = FakeForestOps() + forest = Forest( + name="old-name", + worktrees=["wt1"], + created_at="2025-01-01T00:00:00Z", + root_branch="main", + ) + forest_ops.save_forests(ForestMetadata(forests={"old-name": forest})) + + ctx = env.build_context(git_ops=git_ops, forest_ops=forest_ops) + + result = runner.invoke(cli, ["forest", "rename", "old-name", "new-name"], obj=ctx) + + assert result.exit_code == 0 + assert "Renamed" in result.output + assert "paths remain unchanged" in result.output + + # Verify metadata updated + metadata = forest_ops.load_forests() + assert "new-name" in metadata.forests + assert "old-name" not in metadata.forests + + +def test_forest_rename_conflict() -> None: + """Test renaming to existing forest name.""" + runner = CliRunner() + + with erk_inmem_env(runner) as env: + git_ops = FakeGitOps( + git_common_dirs={env.cwd: env.git_dir}, + ) + + forest_ops = FakeForestOps() + forests = { + "forest1": Forest( + name="forest1", + worktrees=["wt1"], + created_at="2025-01-01T00:00:00Z", + root_branch="main", + ), + "forest2": Forest( + name="forest2", + worktrees=["wt2"], + created_at="2025-01-01T00:00:00Z", + root_branch="main", + ), + } + forest_ops.save_forests(ForestMetadata(forests=forests)) + + ctx = env.build_context(git_ops=git_ops, forest_ops=forest_ops) + + result = runner.invoke(cli, ["forest", "rename", "forest1", "forest2"], obj=ctx) + + assert result.exit_code == 1 + assert "already exists" in result.output