Skip to content
Closed
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 src/erk/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ 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)
cli.add_command(delete_cmd)
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)
Expand Down
29 changes: 11 additions & 18 deletions src/erk/cli/commands/forest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
63 changes: 63 additions & 0 deletions src/erk/cli/commands/forest/list.py
Original file line number Diff line number Diff line change
@@ -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}")
82 changes: 82 additions & 0 deletions src/erk/cli/commands/forest/rename.py
Original file line number Diff line number Diff line change
@@ -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."
)
99 changes: 99 additions & 0 deletions src/erk/cli/commands/forest/show.py
Original file line number Diff line number Diff line change
@@ -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}")
66 changes: 66 additions & 0 deletions src/erk/cli/commands/forest/show_current.py
Original file line number Diff line number Diff line change
@@ -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}")
Loading
Loading