diff --git a/Agents.md b/Agents.md index de81990..c65b5b4 100644 --- a/Agents.md +++ b/Agents.md @@ -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/ +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= # 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/` 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` + diff --git a/tools/wt-worktree/README.md b/tools/wt-worktree/README.md index d196e5f..205ecce 100644 --- a/tools/wt-worktree/README.md +++ b/tools/wt-worktree/README.md @@ -105,6 +105,7 @@ Switch to a worktree, optionally creating it. wt switch # Switch to existing worktree wt switch -c # Create and switch wt switch -c -b # Create from specific base +wt switch -c --detached # Create detached worktree wt switch - # Switch to previous worktree wt switch ^ # Switch to default worktree ``` @@ -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 @@ -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. diff --git a/tools/wt-worktree/notes.md b/tools/wt-worktree/notes.md index b8d44e9..e9a5bfe 100644 --- a/tools/wt-worktree/notes.md +++ b/tools/wt-worktree/notes.md @@ -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 --detached` + - Solution: + - Store user-given names in worktree-specific git config using `git config --worktree worktree.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-)` + - 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-)` + ### Future Improvements 1. **Increase CLI Test Coverage**: Add more edge case tests for CLI commands diff --git a/tools/wt-worktree/tests/test_cli.py b/tools/wt-worktree/tests/test_cli.py index 63b826e..c50453b 100644 --- a/tools/wt-worktree/tests/test_cli.py +++ b/tools/wt-worktree/tests/test_cli.py @@ -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 diff --git a/tools/wt-worktree/wt/git.py b/tools/wt-worktree/wt/git.py index f3dcfea..40c3736 100644 --- a/tools/wt-worktree/wt/git.py +++ b/tools/wt-worktree/wt/git.py @@ -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 diff --git a/tools/wt-worktree/wt/worktree.py b/tools/wt-worktree/wt/worktree.py index 7888193..a5f8828 100644 --- a/tools/wt-worktree/wt/worktree.py +++ b/tools/wt-worktree/wt/worktree.py @@ -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. @@ -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 @@ -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 @@ -162,7 +204,12 @@ 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: @@ -170,6 +217,10 @@ def create_worktree(self, name: str, base: Optional[str] = None, 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: