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
27 changes: 27 additions & 0 deletions Agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,31 @@ Do NOT include full copies of code that you fetched as part of your investigatio

After everything is done update the root README.md with the tool's information

## Running Tests

**First time setup:**
```bash
cd tools/<tool-name>
uv pip install -e ".[dev]" # Install with dev dependencies
```

**Run tests:**
```bash
uv run pytest tests/ -v # Verbose output
uv run pytest tests/ -v --cov=<pkg> # With coverage (if configured in pyproject.toml)
uv run pytest tests/test_foo.py::test_bar -v # Run specific test
```

**Note:** Tests in this repo use real operations (not mocks) and temporary directories. If a test fails, check the error output for temp paths.

## Workflow Tips

1. **Always cd to tool directory first**: `cd tools/<tool-name>` before running commands
2. **Check pyproject.toml**: Review dependencies, scripts, and pytest config
3. **Read existing tests**: Understand test patterns and fixtures before adding new ones
4. **Use TodoWrite tool**: Track progress on multi-step tasks
5. **Document learnings**: Add to notes.md as you discover gotchas or solutions
6. **Test as you go**: Run tests after each significant change, not just at the end
7. **Git config in tests**: Disable GPG signing in test fixtures: `git config commit.gpgsign false`


21 changes: 21 additions & 0 deletions tools/wt-worktree/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ Switch to a worktree, optionally creating it.
wt switch <name> # Switch to existing worktree
wt switch -c <name> # Create and switch
wt switch -c <name> -b <base> # Create from specific base
wt switch -c <name> --detached # Create detached worktree
wt switch - # Switch to previous worktree
wt switch ^ # Switch to default worktree
```
Expand All @@ -121,6 +122,9 @@ wt switch -c feat
# Create from specific base branch
wt switch -c hotfix -b origin/release-1.0

# Create detached worktree (no branch, useful for experiments)
wt switch -c experiment --detached

# Toggle between worktrees
wt switch feat
wt switch other
Expand All @@ -130,6 +134,23 @@ wt switch - # Back to feat
wt switch ^
```

**Detached Worktrees:**

Detached worktrees are not on any branch - they point directly to a commit. They're useful for temporary work, experiments, or reviewing specific commits without affecting any branches. Each detached worktree preserves the name you give it, so you can easily list, switch to, and manage multiple detached worktrees:

```bash
# Create multiple detached worktrees
wt switch -c review-pr-123 --detached
wt switch -c experiment-new-arch --detached

# List shows each with its unique name (not generic "detached")
wt list

# Switch between them using their names
wt switch review-pr-123
wt run experiment-new-arch "pytest"
```

### `wt list`

List all worktrees with their status.
Expand Down
18 changes: 18 additions & 0 deletions tools/wt-worktree/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,24 @@ wt-worktree/
- Tests: Added 6 comprehensive tests covering all options and edge cases
- Lesson: When implementing operations that process multiple items, use warning/info functions instead of error() to avoid early exit

9. **Detached Worktree Name Preservation**
- Problem: Multiple detached worktrees all showed as "(Detached)" in lists, making them indistinguishable
- Problem: Couldn't switch to detached worktrees by the name given during `wt switch -c <name> --detached`
- Solution:
- Store user-given names in worktree-specific git config using `git config --worktree worktree.name <name>`
- Retrieve stored names when listing worktrees
- Enable `extensions.worktreeConfig` to support per-worktree config
- Implementation Details:
- Added `enable_worktree_config()`, `set_worktree_name()`, and `get_worktree_name()` functions in git.py
- Modified `create_worktree()` to store names for detached worktrees
- Modified `list_worktrees()` to retrieve stored names or fallback to `(detached-<commit>)`
- Fixed base branch selection: detached worktrees now use HEAD instead of default_base
- Key Insight: Git's `--worktree` config flag requires `extensions.worktreeConfig` to be enabled first
- Tests: Added 6 comprehensive tests for detached worktree creation, listing, switching, running commands, and deletion
- Lesson: Per-worktree config in git requires enabling the worktreeConfig extension, and is the right way to store worktree-specific metadata
- Backward Compatibility: Added `_infer_name_from_path()` to infer names from path patterns for detached worktrees created before this fix or via raw git commands
- Fallback chain: stored config → inferred from path → `(detached-<commit>)`

### Future Improvements

1. **Increase CLI Test Coverage**: Add more edge case tests for CLI commands
Expand Down
128 changes: 128 additions & 0 deletions tools/wt-worktree/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,131 @@ def test_sync_command_invalid_args(runner, initialized_repo):
result = runner.invoke(cli, ["sync", "--exclude", "feat1"])
assert result.exit_code == 2
assert "requires --all" in result.output


def test_detached_worktree_create(runner, initialized_repo, no_prompt):
"""Test creating a detached worktree."""
result = runner.invoke(cli, ["switch", "-c", "mydetached", "--detached"])
assert result.exit_code == 0
# Worktree should be created
from wt.config import Config
from wt.worktree import WorktreeManager
config = Config(initialized_repo)
manager = WorktreeManager(config)
wt = manager.find_worktree_by_name("mydetached")
assert wt is not None
assert wt["name"] == "mydetached"
assert wt.get("branch") is None # detached worktrees have no branch


def test_detached_worktree_list(runner, initialized_repo, no_prompt):
"""Test listing detached worktrees shows custom names."""
# Create two detached worktrees
runner.invoke(cli, ["switch", "-c", "detached1", "--detached"])
runner.invoke(cli, ["switch", "-c", "detached2", "--detached"])

result = runner.invoke(cli, ["list"])
assert result.exit_code == 0
# Both should show with their custom names, not "(detached)"
assert "detached1" in result.output
assert "detached2" in result.output
# Should not show generic "(detached)" for named worktrees
lines = result.output.split('\n')
detached_lines = [l for l in lines if "detached1" in l or "detached2" in l]
assert len(detached_lines) == 2


def test_detached_worktree_switch(runner, initialized_repo, no_prompt):
"""Test switching to a detached worktree by its name."""
# Create a detached worktree
runner.invoke(cli, ["switch", "-c", "mydetached", "--detached"])

# Switch to it by name
result = runner.invoke(cli, ["switch", "mydetached"])
assert result.exit_code == 0
assert "mydetached" in result.output


def test_detached_worktree_run(runner, initialized_repo, no_prompt):
"""Test running commands in a detached worktree."""
# Create a detached worktree
runner.invoke(cli, ["switch", "-c", "mydetached", "--detached"])

# Run a command in it
result = runner.invoke(cli, ["run", "mydetached", "echo hello"])
assert result.exit_code == 0


def test_multiple_detached_worktrees_unique_names(runner, initialized_repo, no_prompt):
"""Test that multiple detached worktrees can coexist with unique names."""
# Create multiple detached worktrees
runner.invoke(cli, ["switch", "-c", "det1", "--detached"])
runner.invoke(cli, ["switch", "-c", "det2", "--detached"])
runner.invoke(cli, ["switch", "-c", "det3", "--detached"])

# List should show all three with their unique names
result = runner.invoke(cli, ["list"])
assert result.exit_code == 0
assert "det1" in result.output
assert "det2" in result.output
assert "det3" in result.output

# Each should be findable by name
from wt.config import Config
from wt.worktree import WorktreeManager
config = Config(initialized_repo)
manager = WorktreeManager(config)

for name in ["det1", "det2", "det3"]:
wt = manager.find_worktree_by_name(name)
assert wt is not None
assert wt["name"] == name


def test_detached_worktree_delete(runner, initialized_repo, no_prompt):
"""Test deleting a detached worktree by its name."""
# Create a detached worktree
runner.invoke(cli, ["switch", "-c", "mydetached", "--detached"])

# Delete it by name
result = runner.invoke(cli, ["delete", "mydetached", "--force"])
assert result.exit_code == 0

# Verify it's gone
from wt.config import Config
from wt.worktree import WorktreeManager
config = Config(initialized_repo)
manager = WorktreeManager(config)
wt = manager.find_worktree_by_name("mydetached")
assert wt is None


def test_detached_worktree_backward_compatibility(runner, initialized_repo, no_prompt):
"""Test that detached worktrees created without stored name still work."""
# Create a detached worktree using raw git (simulates old behavior)
from wt import git
from wt.config import Config
from wt.worktree import WorktreeManager

config = Config(initialized_repo)
wt_path = config.resolve_path_pattern("legacy", "feature/legacy")
git.add_worktree(wt_path, "legacy", create_branch=False, base="HEAD",
detached=True, repo_path=initialized_repo)

# Note: Not calling set_worktree_name - simulates old behavior

# List should infer name from path
manager = WorktreeManager(config)
worktrees = manager.list_worktrees()
legacy_wt = None
for wt in worktrees:
if "legacy" in wt["name"]:
legacy_wt = wt
break

assert legacy_wt is not None
assert legacy_wt["name"] == "legacy" # Inferred from path

# Should be able to find it by inferred name
found_wt = manager.find_worktree_by_name("legacy")
assert found_wt is not None
49 changes: 49 additions & 0 deletions tools/wt-worktree/wt/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,3 +491,52 @@ def fetch_remote(remote: str = "origin", path: Optional[Path] = None):
path: Repository path
"""
run_git(["fetch", remote], cwd=path)


def enable_worktree_config(path: Path):
"""
Enable worktree-specific config support.

This must be called before using --worktree flag in git config.

Args:
path: Path to any worktree in the repository
"""
# Check if already enabled
result = run_git(["config", "extensions.worktreeConfig"], cwd=path, check=False)
if result.returncode != 0 or result.stdout.strip() != "true":
# Enable it
run_git(["config", "extensions.worktreeConfig", "true"], cwd=path)


def set_worktree_name(name: str, path: Path):
"""
Store worktree name in the worktree's config.

This is useful for detached worktrees where the branch name is not available.
Uses --worktree flag to ensure config is stored per-worktree, not globally.

Args:
name: Worktree name to store
path: Path to the worktree
"""
# Enable worktree config extension if not already enabled
enable_worktree_config(path)
# Set the worktree-specific config
run_git(["config", "--worktree", "worktree.name", name], cwd=path)


def get_worktree_name(path: Path) -> Optional[str]:
"""
Get worktree name from the worktree's config.

Args:
path: Path to the worktree

Returns:
Worktree name if set, None otherwise
"""
result = run_git(["config", "--worktree", "worktree.name"], cwd=path, check=False)
if result.returncode == 0:
return result.stdout.strip()
return None
59 changes: 55 additions & 4 deletions tools/wt-worktree/wt/worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,37 @@ def __init__(self, config: Config):
self.config = config
self.repo_root = config.repo_root

def _infer_name_from_path(self, wt_path: Path) -> Optional[str]:
"""
Try to infer worktree name from its path based on path_pattern.

Provides backward compatibility for detached worktrees created before
the name-storing feature was added.

Args:
wt_path: Path to the worktree

Returns:
Inferred name or None
"""
# Get the pattern and try common formats
pattern = self.config.get("path_pattern")
repo_name = self.repo_root.name

# Try pattern: ../{repo}-{name}
if pattern == "../{repo}-{name}":
expected_prefix = f"{repo_name}-"
if wt_path.name.startswith(expected_prefix):
return wt_path.name[len(expected_prefix):]

# Try pattern: ../{name}
elif pattern == "../{name}":
# Exclude the main worktree
if wt_path != self.repo_root:
return wt_path.name

return None

def list_worktrees(self) -> List[dict]:
"""
List all worktrees with enhanced information.
Expand All @@ -36,11 +67,22 @@ def list_worktrees(self) -> List[dict]:
except git.GitError:
wt["message"] = ""

# Extract worktree name from branch
# Extract worktree name from branch or config
if wt.get("branch"):
wt["name"] = self.config.extract_worktree_name(wt["branch"])
else:
wt["name"] = "(detached)"
# For detached worktrees, try multiple sources
stored_name = git.get_worktree_name(wt["path"])
if stored_name:
wt["name"] = stored_name
else:
# Try to infer from path (backward compatibility)
inferred_name = self._infer_name_from_path(wt["path"])
if inferred_name:
wt["name"] = inferred_name
else:
# Fallback: use commit hash as identifier
wt["name"] = f"(detached-{wt['commit'][:7]})"

return worktrees

Expand Down Expand Up @@ -79,7 +121,7 @@ def find_worktree_by_name(self, name: str) -> Optional[dict]:
"""
worktrees = self.list_worktrees()

# Try exact match on name first
# Try exact match on name first (works for both regular and detached worktrees)
for wt in worktrees:
if wt.get("name") == name:
return wt
Expand Down Expand Up @@ -162,14 +204,23 @@ def create_worktree(self, name: str, base: Optional[str] = None,

# Determine base branch
if base is None:
base = self.config.get("default_base")
# For detached worktrees, use HEAD by default (not default_base)
# default_base is meant for creating new branches, not detached worktrees
if detached:
base = "HEAD"
else:
base = self.config.get("default_base")

# Create worktree
try:
git.add_worktree(wt_path, branch, create_branch, base, detached, self.repo_root)
except git.GitError as e:
raise git.GitError(f"Failed to create worktree: {e}")

# Store worktree name in config if detached (so we can find it later)
if detached:
git.set_worktree_name(name, wt_path)

# Configure push remote if not detached
if not detached and create_branch:
try:
Expand Down