From 79df26f691ed2ac06e126dd369135af36462666a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 07:59:54 +0000 Subject: [PATCH 1/3] Fix detached worktree name handling Previously, all detached worktrees showed as "(Detached)" in lists, making them indistinguishable. Also, couldn't switch to detached worktrees by the name given during creation. Changes: - Store user-given names in per-worktree git config using worktreeConfig extension - Enable extensions.worktreeConfig to support per-worktree config - Retrieve stored names when listing worktrees, fallback to (detached-) - Fix base branch selection: detached worktrees now use HEAD instead of default_base - Add comprehensive tests for detached worktree operations Implementation: - Added enable_worktree_config(), set_worktree_name(), get_worktree_name() in git.py - Modified create_worktree() to store names for detached worktrees - Modified list_worktrees() to retrieve stored names - find_worktree_by_name() now works for detached worktrees This fix makes detached worktrees fully functional for list/switch/run/delete commands. --- tools/wt-worktree/README.md | 21 +++++++ tools/wt-worktree/notes.md | 16 +++++ tools/wt-worktree/tests/test_cli.py | 97 +++++++++++++++++++++++++++++ tools/wt-worktree/wt/git.py | 49 +++++++++++++++ tools/wt-worktree/wt/worktree.py | 23 +++++-- 5 files changed, 202 insertions(+), 4 deletions(-) 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..0cd3a59 100644 --- a/tools/wt-worktree/notes.md +++ b/tools/wt-worktree/notes.md @@ -154,6 +154,22 @@ 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 + ### 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..c46b7b7 100644 --- a/tools/wt-worktree/tests/test_cli.py +++ b/tools/wt-worktree/tests/test_cli.py @@ -307,3 +307,100 @@ 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 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..71c7c1c 100644 --- a/tools/wt-worktree/wt/worktree.py +++ b/tools/wt-worktree/wt/worktree.py @@ -36,11 +36,17 @@ 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 to get name from git config + stored_name = git.get_worktree_name(wt["path"]) + if stored_name: + wt["name"] = stored_name + else: + # Fallback: use commit hash as identifier + wt["name"] = f"(detached-{wt['commit'][:7]})" return worktrees @@ -79,7 +85,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 +168,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 +181,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: From 0919308b3953171ffea39aeab49dd405332d59ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 08:01:46 +0000 Subject: [PATCH 2/3] Update Agents.md with testing and workflow tips --- Agents.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) 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` + From 6bde08409f1737b9bfdc10226bb5bf20087384e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 08:12:41 +0000 Subject: [PATCH 3/3] Add backward compatibility for detached worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detached worktrees created before this fix (or via raw git commands) don't have stored names in config. Added path-based inference to handle these cases. Changes: - Added _infer_name_from_path() method to infer names from worktree paths - Fallback chain: stored config → inferred from path → (detached-) - Works with common path patterns like ../{repo}-{name} and ../{name} - Added test for backward compatibility scenario This ensures switch/list/run commands work even for legacy detached worktrees. --- tools/wt-worktree/notes.md | 2 ++ tools/wt-worktree/tests/test_cli.py | 31 +++++++++++++++++++++ tools/wt-worktree/wt/worktree.py | 42 ++++++++++++++++++++++++++--- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/tools/wt-worktree/notes.md b/tools/wt-worktree/notes.md index 0cd3a59..e9a5bfe 100644 --- a/tools/wt-worktree/notes.md +++ b/tools/wt-worktree/notes.md @@ -169,6 +169,8 @@ wt-worktree/ - 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 diff --git a/tools/wt-worktree/tests/test_cli.py b/tools/wt-worktree/tests/test_cli.py index c46b7b7..c50453b 100644 --- a/tools/wt-worktree/tests/test_cli.py +++ b/tools/wt-worktree/tests/test_cli.py @@ -404,3 +404,34 @@ def test_detached_worktree_delete(runner, initialized_repo, no_prompt): 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/worktree.py b/tools/wt-worktree/wt/worktree.py index 71c7c1c..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. @@ -40,13 +71,18 @@ def list_worktrees(self) -> List[dict]: if wt.get("branch"): wt["name"] = self.config.extract_worktree_name(wt["branch"]) else: - # For detached worktrees, try to get name from git config + # For detached worktrees, try multiple sources stored_name = git.get_worktree_name(wt["path"]) if stored_name: wt["name"] = stored_name else: - # Fallback: use commit hash as identifier - wt["name"] = f"(detached-{wt['commit'][:7]})" + # 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