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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `--path` flag for `sprout create` command to output only the worktree path, enabling one-liner usage like `cd $(sprout create feature --path)`

### Changed
- Enhanced `sprout path` and `sprout rm` commands to accept index numbers from `sprout ls` output, enabling faster navigation without typing full branch names (e.g., `cd $(sprout path 2)` instead of `cd $(sprout path feature-long-branch-name)`)

### Deprecated

Expand Down
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,47 @@ cd $(sprout create feature-xyz --path)
### `sprout ls`
List all managed development environments with their status.

### `sprout rm <branch-name>`
The output includes index numbers that can be used with other commands:
```bash
sprout ls
# Output:
# ┏━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
# ┃ No. ┃ Branch ┃ Path ┃ Status ┃ Last Modified ┃
# ┡━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
# │ 1 │ feature-auth │ .sprout/feat... │ │ 2025-06-27 14:30 │
# │ 2 │ bugfix-api │ .sprout/bugf... │ │ 2025-06-27 15:45 │
# └──────┴─────────────────┴─────────────────┴────────┴──────────────────┘
```

### `sprout rm <branch-name-or-index>`
Remove a development environment (with confirmation prompts).

### `sprout path <branch-name>`
You can use either the branch name or the index number from `sprout ls`:
```bash
# Remove by branch name
sprout rm feature-auth

# Remove by index number
sprout rm 1
```

### `sprout path <branch-name-or-index>`
Get the filesystem path of a development environment.

You can use either the branch name or the index number from `sprout ls`:
```bash
# Get path by branch name
sprout path feature-auth
# Output: /path/to/project/.sprout/feature-auth

# Get path by index number
sprout path 1
# Output: /path/to/project/.sprout/feature-auth

# Use with cd command
cd $(sprout path 2)
```

### `sprout --version`
Show the version of sprout.

Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,10 @@ ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
disallow_incomplete_defs = false
disallow_incomplete_defs = false

[dependency-groups]
dev = [
"pytest-mock>=3.14.1",
"ruff>=0.12.1",
]
12 changes: 6 additions & 6 deletions src/sprout/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,24 +64,24 @@ def ls() -> None:

@app.command()
def rm(
branch_name: BranchName = typer.Argument(
identifier: str = typer.Argument(
...,
help="Name of the branch to remove",
help="Branch name or index number to remove",
),
) -> None:
"""Remove a development environment."""
remove_worktree(branch_name)
remove_worktree(identifier)


@app.command()
def path(
branch_name: BranchName = typer.Argument(
identifier: str = typer.Argument(
...,
help="Name of the branch to get path for",
help="Branch name or index number to get path for",
),
) -> None:
"""Get the path of a development environment."""
get_worktree_path(branch_name)
get_worktree_path(identifier)


if __name__ == "__main__":
Expand Down
65 changes: 10 additions & 55 deletions src/sprout/commands/ls.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
"""Implementation of the ls command."""

from datetime import datetime
from pathlib import Path

import typer
from rich.console import Console
from rich.table import Table

from sprout.exceptions import SproutError
from sprout.types import WorktreeInfo
from sprout.utils import get_sprout_dir, is_git_repository, run_command
from sprout.utils import get_indexed_worktrees, is_git_repository

console = Console()

Expand All @@ -20,74 +15,34 @@ def list_worktrees() -> None:
console.print("[red]Error: Not in a git repository[/red]")
raise typer.Exit(1)

sprout_dir = get_sprout_dir()

# Get worktree list from git
try:
result = run_command(["git", "worktree", "list", "--porcelain"])
except SproutError as e:
sprout_worktrees = get_indexed_worktrees()
except Exception as e:
console.print(f"[red]Error listing worktrees: {e}[/red]")
raise typer.Exit(1) from e

# Parse worktree output
worktrees: list[WorktreeInfo] = []
current_worktree: WorktreeInfo = {}

for line in result.stdout.strip().split("\n"):
if not line:
if current_worktree:
worktrees.append(current_worktree)
current_worktree = {}
continue

if line.startswith("worktree "):
current_worktree["path"] = Path(line[9:])
elif line.startswith("branch "):
current_worktree["branch"] = line[7:]
elif line.startswith("HEAD "):
current_worktree["head"] = line[5:]

if current_worktree:
worktrees.append(current_worktree)

# Filter for sprout-managed worktrees
sprout_worktrees: list[WorktreeInfo] = []
current_path = Path.cwd().resolve()

for wt in worktrees:
wt_path = wt["path"].resolve()
if wt_path.parent == sprout_dir:
# Check if we're currently in this worktree
wt["is_current"] = current_path == wt_path or current_path.is_relative_to(wt_path)

# Get last modified time
if wt_path.exists():
stat = wt_path.stat()
wt["modified"] = datetime.fromtimestamp(stat.st_mtime)
else:
wt["modified"] = None

sprout_worktrees.append(wt)

if not sprout_worktrees:
console.print("[yellow]No sprout-managed worktrees found.[/yellow]")
console.print("Use 'sprout create <branch-name>' to create one.")
return None

# Create table
# Create table with index column
table = Table(title="Sprout Worktrees", show_lines=True)
table.add_column("No.", style="bright_white", no_wrap=True, width=4)
table.add_column("Branch", style="cyan", no_wrap=True)
table.add_column("Path", style="blue")
table.add_column("Status", style="green")
table.add_column("Last Modified", style="yellow")

for wt in sprout_worktrees:
from pathlib import Path

for idx, wt in enumerate(sprout_worktrees, 1):
branch = wt.get("branch", wt.get("head", "detached"))
path = str(wt["path"].relative_to(Path.cwd()))
status = "[green]● current[/green]" if wt["is_current"] else ""
status = "[green]● current[/green]" if wt.get("is_current", False) else ""
modified_dt = wt.get("modified")
modified = modified_dt.strftime("%Y-%m-%d %H:%M") if modified_dt else "N/A"

table.add_row(branch, path, status, modified)
table.add_row(str(idx), branch, path, status, modified)

console.print(table)
25 changes: 21 additions & 4 deletions src/sprout/commands/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,35 @@

import typer

from sprout.types import BranchName
from sprout.utils import get_sprout_dir, is_git_repository, worktree_exists
from sprout.utils import (
get_sprout_dir,
is_git_repository,
resolve_branch_identifier,
worktree_exists,
)

console: TextIO = typer.get_text_stream("stdout")


def get_worktree_path(branch_name: BranchName) -> Never:
"""Get the path of a development environment."""
def get_worktree_path(identifier: str) -> Never:
"""Get the path of a development environment by branch name or index."""
if not is_git_repository():
typer.echo("Error: Not in a git repository", err=True)
raise typer.Exit(1)

# Resolve identifier to branch name
branch_name = resolve_branch_identifier(identifier)

if branch_name is None:
if identifier.isdigit():
typer.echo(
f"Error: Invalid index '{identifier}'. Use 'sprout ls' to see valid indices.",
err=True,
)
else:
typer.echo(f"Error: Worktree for branch '{identifier}' does not exist", err=True)
raise typer.Exit(1)

# Check if worktree exists
if not worktree_exists(branch_name):
typer.echo(f"Error: Worktree for branch '{branch_name}' does not exist", err=True)
Expand Down
19 changes: 16 additions & 3 deletions src/sprout/commands/rm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,36 @@
from rich.console import Console

from sprout.exceptions import SproutError
from sprout.types import BranchName
from sprout.utils import (
get_sprout_dir,
is_git_repository,
resolve_branch_identifier,
run_command,
worktree_exists,
)

console = Console()


def remove_worktree(branch_name: BranchName) -> None:
"""Remove a development environment."""
def remove_worktree(identifier: str) -> None:
"""Remove a development environment by branch name or index."""
if not is_git_repository():
console.print("[red]Error: Not in a git repository[/red]")
raise typer.Exit(1)

# Resolve identifier to branch name
branch_name = resolve_branch_identifier(identifier)

if branch_name is None:
if identifier.isdigit():
console.print(
f"[red]Error: Invalid index '{identifier}'. "
"Use 'sprout ls' to see valid indices.[/red]"
)
else:
console.print(f"[red]Error: Worktree for branch '{identifier}' does not exist[/red]")
raise typer.Exit(1)

# Check if worktree exists
if not worktree_exists(branch_name):
console.print(f"[red]Error: Worktree for branch '{branch_name}' does not exist[/red]")
Expand Down
90 changes: 89 additions & 1 deletion src/sprout/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
import re
import socket
import subprocess
from datetime import datetime
from pathlib import Path
from typing import TypeAlias

import typer
from rich.console import Console

from sprout.exceptions import SproutError
from sprout.types import BranchName
from sprout.types import BranchName, WorktreeInfo

# Type aliases
PortNumber: TypeAlias = int
Expand Down Expand Up @@ -181,3 +182,90 @@ def branch_exists(branch_name: BranchName) -> bool:
"""Check if a git branch exists."""
result = run_command(["git", "rev-parse", "--verify", f"refs/heads/{branch_name}"], check=False)
return result.returncode == 0


def get_indexed_worktrees() -> list[WorktreeInfo]:
"""Get a list of sprout-managed worktrees with consistent ordering.

Returns:
List of WorktreeInfo dicts, sorted by branch name for consistent indexing.
"""
if not is_git_repository():
raise SproutError("Not in a git repository")

sprout_dir = get_sprout_dir()

# Get worktree list from git
result = run_command(["git", "worktree", "list", "--porcelain"])

# Parse worktree output
worktrees: list[WorktreeInfo] = []
current_worktree: WorktreeInfo = {}

for line in result.stdout.strip().split("\n"):
if not line:
if current_worktree:
worktrees.append(current_worktree)
current_worktree = {}
continue

if line.startswith("worktree "):
current_worktree["path"] = Path(line[9:])
elif line.startswith("branch "):
branch_ref = line[7:]
# Strip refs/heads/ prefix if present
if branch_ref.startswith("refs/heads/"):
current_worktree["branch"] = branch_ref[11:]
else:
current_worktree["branch"] = branch_ref
elif line.startswith("HEAD "):
current_worktree["head"] = line[5:]

if current_worktree:
worktrees.append(current_worktree)

# Filter for sprout-managed worktrees
sprout_worktrees: list[WorktreeInfo] = []
current_path = Path.cwd().resolve()

for wt in worktrees:
wt_path = wt["path"].resolve()
if wt_path.parent == sprout_dir:
# Check if we're currently in this worktree
wt["is_current"] = current_path == wt_path or current_path.is_relative_to(wt_path)

# Get last modified time
if wt_path.exists():
stat = wt_path.stat()
wt["modified"] = datetime.fromtimestamp(stat.st_mtime)
else:
wt["modified"] = None

sprout_worktrees.append(wt)

# Sort by branch name for consistent indexing
sprout_worktrees.sort(key=lambda wt: wt.get("branch") or wt.get("head") or "")

return sprout_worktrees


def resolve_branch_identifier(identifier: str) -> BranchName | None:
"""Resolve a branch identifier (name or index) to a branch name.

Args:
identifier: Either a branch name or a 1-based index number

Returns:
Branch name if found, None otherwise
"""
# Check if identifier is a number
if identifier.isdigit():
index = int(identifier)
worktrees = get_indexed_worktrees()

if 1 <= index <= len(worktrees):
return worktrees[index - 1].get("branch", worktrees[index - 1].get("head", ""))
return None

# Otherwise treat as branch name
return identifier
Loading