Skip to content

Commit 86da0b5

Browse files
authored
Fix detached worktree name handling (#15)
1 parent 242f363 commit 86da0b5

6 files changed

Lines changed: 298 additions & 4 deletions

File tree

Agents.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,31 @@ Do NOT include full copies of code that you fetched as part of your investigatio
2323

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

26+
## Running Tests
27+
28+
**First time setup:**
29+
```bash
30+
cd tools/<tool-name>
31+
uv pip install -e ".[dev]" # Install with dev dependencies
32+
```
33+
34+
**Run tests:**
35+
```bash
36+
uv run pytest tests/ -v # Verbose output
37+
uv run pytest tests/ -v --cov=<pkg> # With coverage (if configured in pyproject.toml)
38+
uv run pytest tests/test_foo.py::test_bar -v # Run specific test
39+
```
40+
41+
**Note:** Tests in this repo use real operations (not mocks) and temporary directories. If a test fails, check the error output for temp paths.
42+
43+
## Workflow Tips
44+
45+
1. **Always cd to tool directory first**: `cd tools/<tool-name>` before running commands
46+
2. **Check pyproject.toml**: Review dependencies, scripts, and pytest config
47+
3. **Read existing tests**: Understand test patterns and fixtures before adding new ones
48+
4. **Use TodoWrite tool**: Track progress on multi-step tasks
49+
5. **Document learnings**: Add to notes.md as you discover gotchas or solutions
50+
6. **Test as you go**: Run tests after each significant change, not just at the end
51+
7. **Git config in tests**: Disable GPG signing in test fixtures: `git config commit.gpgsign false`
52+
2653

tools/wt-worktree/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ Switch to a worktree, optionally creating it.
105105
wt switch <name> # Switch to existing worktree
106106
wt switch -c <name> # Create and switch
107107
wt switch -c <name> -b <base> # Create from specific base
108+
wt switch -c <name> --detached # Create detached worktree
108109
wt switch - # Switch to previous worktree
109110
wt switch ^ # Switch to default worktree
110111
```
@@ -121,6 +122,9 @@ wt switch -c feat
121122
# Create from specific base branch
122123
wt switch -c hotfix -b origin/release-1.0
123124

125+
# Create detached worktree (no branch, useful for experiments)
126+
wt switch -c experiment --detached
127+
124128
# Toggle between worktrees
125129
wt switch feat
126130
wt switch other
@@ -130,6 +134,23 @@ wt switch - # Back to feat
130134
wt switch ^
131135
```
132136

137+
**Detached Worktrees:**
138+
139+
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:
140+
141+
```bash
142+
# Create multiple detached worktrees
143+
wt switch -c review-pr-123 --detached
144+
wt switch -c experiment-new-arch --detached
145+
146+
# List shows each with its unique name (not generic "detached")
147+
wt list
148+
149+
# Switch between them using their names
150+
wt switch review-pr-123
151+
wt run experiment-new-arch "pytest"
152+
```
153+
133154
### `wt list`
134155

135156
List all worktrees with their status.

tools/wt-worktree/notes.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,24 @@ wt-worktree/
154154
- Tests: Added 6 comprehensive tests covering all options and edge cases
155155
- Lesson: When implementing operations that process multiple items, use warning/info functions instead of error() to avoid early exit
156156

157+
9. **Detached Worktree Name Preservation**
158+
- Problem: Multiple detached worktrees all showed as "(Detached)" in lists, making them indistinguishable
159+
- Problem: Couldn't switch to detached worktrees by the name given during `wt switch -c <name> --detached`
160+
- Solution:
161+
- Store user-given names in worktree-specific git config using `git config --worktree worktree.name <name>`
162+
- Retrieve stored names when listing worktrees
163+
- Enable `extensions.worktreeConfig` to support per-worktree config
164+
- Implementation Details:
165+
- Added `enable_worktree_config()`, `set_worktree_name()`, and `get_worktree_name()` functions in git.py
166+
- Modified `create_worktree()` to store names for detached worktrees
167+
- Modified `list_worktrees()` to retrieve stored names or fallback to `(detached-<commit>)`
168+
- Fixed base branch selection: detached worktrees now use HEAD instead of default_base
169+
- Key Insight: Git's `--worktree` config flag requires `extensions.worktreeConfig` to be enabled first
170+
- Tests: Added 6 comprehensive tests for detached worktree creation, listing, switching, running commands, and deletion
171+
- Lesson: Per-worktree config in git requires enabling the worktreeConfig extension, and is the right way to store worktree-specific metadata
172+
- 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
173+
- Fallback chain: stored config → inferred from path → `(detached-<commit>)`
174+
157175
### Future Improvements
158176

159177
1. **Increase CLI Test Coverage**: Add more edge case tests for CLI commands

tools/wt-worktree/tests/test_cli.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,131 @@ def test_sync_command_invalid_args(runner, initialized_repo):
307307
result = runner.invoke(cli, ["sync", "--exclude", "feat1"])
308308
assert result.exit_code == 2
309309
assert "requires --all" in result.output
310+
311+
312+
def test_detached_worktree_create(runner, initialized_repo, no_prompt):
313+
"""Test creating a detached worktree."""
314+
result = runner.invoke(cli, ["switch", "-c", "mydetached", "--detached"])
315+
assert result.exit_code == 0
316+
# Worktree should be created
317+
from wt.config import Config
318+
from wt.worktree import WorktreeManager
319+
config = Config(initialized_repo)
320+
manager = WorktreeManager(config)
321+
wt = manager.find_worktree_by_name("mydetached")
322+
assert wt is not None
323+
assert wt["name"] == "mydetached"
324+
assert wt.get("branch") is None # detached worktrees have no branch
325+
326+
327+
def test_detached_worktree_list(runner, initialized_repo, no_prompt):
328+
"""Test listing detached worktrees shows custom names."""
329+
# Create two detached worktrees
330+
runner.invoke(cli, ["switch", "-c", "detached1", "--detached"])
331+
runner.invoke(cli, ["switch", "-c", "detached2", "--detached"])
332+
333+
result = runner.invoke(cli, ["list"])
334+
assert result.exit_code == 0
335+
# Both should show with their custom names, not "(detached)"
336+
assert "detached1" in result.output
337+
assert "detached2" in result.output
338+
# Should not show generic "(detached)" for named worktrees
339+
lines = result.output.split('\n')
340+
detached_lines = [l for l in lines if "detached1" in l or "detached2" in l]
341+
assert len(detached_lines) == 2
342+
343+
344+
def test_detached_worktree_switch(runner, initialized_repo, no_prompt):
345+
"""Test switching to a detached worktree by its name."""
346+
# Create a detached worktree
347+
runner.invoke(cli, ["switch", "-c", "mydetached", "--detached"])
348+
349+
# Switch to it by name
350+
result = runner.invoke(cli, ["switch", "mydetached"])
351+
assert result.exit_code == 0
352+
assert "mydetached" in result.output
353+
354+
355+
def test_detached_worktree_run(runner, initialized_repo, no_prompt):
356+
"""Test running commands in a detached worktree."""
357+
# Create a detached worktree
358+
runner.invoke(cli, ["switch", "-c", "mydetached", "--detached"])
359+
360+
# Run a command in it
361+
result = runner.invoke(cli, ["run", "mydetached", "echo hello"])
362+
assert result.exit_code == 0
363+
364+
365+
def test_multiple_detached_worktrees_unique_names(runner, initialized_repo, no_prompt):
366+
"""Test that multiple detached worktrees can coexist with unique names."""
367+
# Create multiple detached worktrees
368+
runner.invoke(cli, ["switch", "-c", "det1", "--detached"])
369+
runner.invoke(cli, ["switch", "-c", "det2", "--detached"])
370+
runner.invoke(cli, ["switch", "-c", "det3", "--detached"])
371+
372+
# List should show all three with their unique names
373+
result = runner.invoke(cli, ["list"])
374+
assert result.exit_code == 0
375+
assert "det1" in result.output
376+
assert "det2" in result.output
377+
assert "det3" in result.output
378+
379+
# Each should be findable by name
380+
from wt.config import Config
381+
from wt.worktree import WorktreeManager
382+
config = Config(initialized_repo)
383+
manager = WorktreeManager(config)
384+
385+
for name in ["det1", "det2", "det3"]:
386+
wt = manager.find_worktree_by_name(name)
387+
assert wt is not None
388+
assert wt["name"] == name
389+
390+
391+
def test_detached_worktree_delete(runner, initialized_repo, no_prompt):
392+
"""Test deleting a detached worktree by its name."""
393+
# Create a detached worktree
394+
runner.invoke(cli, ["switch", "-c", "mydetached", "--detached"])
395+
396+
# Delete it by name
397+
result = runner.invoke(cli, ["delete", "mydetached", "--force"])
398+
assert result.exit_code == 0
399+
400+
# Verify it's gone
401+
from wt.config import Config
402+
from wt.worktree import WorktreeManager
403+
config = Config(initialized_repo)
404+
manager = WorktreeManager(config)
405+
wt = manager.find_worktree_by_name("mydetached")
406+
assert wt is None
407+
408+
409+
def test_detached_worktree_backward_compatibility(runner, initialized_repo, no_prompt):
410+
"""Test that detached worktrees created without stored name still work."""
411+
# Create a detached worktree using raw git (simulates old behavior)
412+
from wt import git
413+
from wt.config import Config
414+
from wt.worktree import WorktreeManager
415+
416+
config = Config(initialized_repo)
417+
wt_path = config.resolve_path_pattern("legacy", "feature/legacy")
418+
git.add_worktree(wt_path, "legacy", create_branch=False, base="HEAD",
419+
detached=True, repo_path=initialized_repo)
420+
421+
# Note: Not calling set_worktree_name - simulates old behavior
422+
423+
# List should infer name from path
424+
manager = WorktreeManager(config)
425+
worktrees = manager.list_worktrees()
426+
legacy_wt = None
427+
for wt in worktrees:
428+
if "legacy" in wt["name"]:
429+
legacy_wt = wt
430+
break
431+
432+
assert legacy_wt is not None
433+
assert legacy_wt["name"] == "legacy" # Inferred from path
434+
435+
# Should be able to find it by inferred name
436+
found_wt = manager.find_worktree_by_name("legacy")
437+
assert found_wt is not None

tools/wt-worktree/wt/git.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,3 +491,52 @@ def fetch_remote(remote: str = "origin", path: Optional[Path] = None):
491491
path: Repository path
492492
"""
493493
run_git(["fetch", remote], cwd=path)
494+
495+
496+
def enable_worktree_config(path: Path):
497+
"""
498+
Enable worktree-specific config support.
499+
500+
This must be called before using --worktree flag in git config.
501+
502+
Args:
503+
path: Path to any worktree in the repository
504+
"""
505+
# Check if already enabled
506+
result = run_git(["config", "extensions.worktreeConfig"], cwd=path, check=False)
507+
if result.returncode != 0 or result.stdout.strip() != "true":
508+
# Enable it
509+
run_git(["config", "extensions.worktreeConfig", "true"], cwd=path)
510+
511+
512+
def set_worktree_name(name: str, path: Path):
513+
"""
514+
Store worktree name in the worktree's config.
515+
516+
This is useful for detached worktrees where the branch name is not available.
517+
Uses --worktree flag to ensure config is stored per-worktree, not globally.
518+
519+
Args:
520+
name: Worktree name to store
521+
path: Path to the worktree
522+
"""
523+
# Enable worktree config extension if not already enabled
524+
enable_worktree_config(path)
525+
# Set the worktree-specific config
526+
run_git(["config", "--worktree", "worktree.name", name], cwd=path)
527+
528+
529+
def get_worktree_name(path: Path) -> Optional[str]:
530+
"""
531+
Get worktree name from the worktree's config.
532+
533+
Args:
534+
path: Path to the worktree
535+
536+
Returns:
537+
Worktree name if set, None otherwise
538+
"""
539+
result = run_git(["config", "--worktree", "worktree.name"], cwd=path, check=False)
540+
if result.returncode == 0:
541+
return result.stdout.strip()
542+
return None

tools/wt-worktree/wt/worktree.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,37 @@ def __init__(self, config: Config):
2020
self.config = config
2121
self.repo_root = config.repo_root
2222

23+
def _infer_name_from_path(self, wt_path: Path) -> Optional[str]:
24+
"""
25+
Try to infer worktree name from its path based on path_pattern.
26+
27+
Provides backward compatibility for detached worktrees created before
28+
the name-storing feature was added.
29+
30+
Args:
31+
wt_path: Path to the worktree
32+
33+
Returns:
34+
Inferred name or None
35+
"""
36+
# Get the pattern and try common formats
37+
pattern = self.config.get("path_pattern")
38+
repo_name = self.repo_root.name
39+
40+
# Try pattern: ../{repo}-{name}
41+
if pattern == "../{repo}-{name}":
42+
expected_prefix = f"{repo_name}-"
43+
if wt_path.name.startswith(expected_prefix):
44+
return wt_path.name[len(expected_prefix):]
45+
46+
# Try pattern: ../{name}
47+
elif pattern == "../{name}":
48+
# Exclude the main worktree
49+
if wt_path != self.repo_root:
50+
return wt_path.name
51+
52+
return None
53+
2354
def list_worktrees(self) -> List[dict]:
2455
"""
2556
List all worktrees with enhanced information.
@@ -36,11 +67,22 @@ def list_worktrees(self) -> List[dict]:
3667
except git.GitError:
3768
wt["message"] = ""
3869

39-
# Extract worktree name from branch
70+
# Extract worktree name from branch or config
4071
if wt.get("branch"):
4172
wt["name"] = self.config.extract_worktree_name(wt["branch"])
4273
else:
43-
wt["name"] = "(detached)"
74+
# For detached worktrees, try multiple sources
75+
stored_name = git.get_worktree_name(wt["path"])
76+
if stored_name:
77+
wt["name"] = stored_name
78+
else:
79+
# Try to infer from path (backward compatibility)
80+
inferred_name = self._infer_name_from_path(wt["path"])
81+
if inferred_name:
82+
wt["name"] = inferred_name
83+
else:
84+
# Fallback: use commit hash as identifier
85+
wt["name"] = f"(detached-{wt['commit'][:7]})"
4486

4587
return worktrees
4688

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

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

163205
# Determine base branch
164206
if base is None:
165-
base = self.config.get("default_base")
207+
# For detached worktrees, use HEAD by default (not default_base)
208+
# default_base is meant for creating new branches, not detached worktrees
209+
if detached:
210+
base = "HEAD"
211+
else:
212+
base = self.config.get("default_base")
166213

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

220+
# Store worktree name in config if detached (so we can find it later)
221+
if detached:
222+
git.set_worktree_name(name, wt_path)
223+
173224
# Configure push remote if not detached
174225
if not detached and create_branch:
175226
try:

0 commit comments

Comments
 (0)