Skip to content

Commit 2d3701b

Browse files
authored
fix(learn): decode directory names with spaces in Windows project paths (headroomlabs-ai#997) (headroomlabs-ai#1027)
## Description `headroom learn --apply` crashes with `FileNotFoundError` when the project lives in a Windows directory whose name contains spaces (e.g. `C:\Users\user\Desktop\Claude Code Projects`). Claude Code encodes that path as `-C-Users-user-Desktop-Claude-Code-Projects`, using `-` for both path separators *and* spaces. The greedy path decoder walks the real filesystem to reconstruct the original components, but `_component_tokenizations()` never tried splitting on spaces — so it couldn't match `Claude Code Projects` against tokens `["Claude", "Code", "Projects"]`. Closes headroomlabs-ai#997 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Added `" "` (space) to the explicit separator list in `_component_tokenizations()` - Updated the catch-all regex from `[-._]` to `[-.\s_]` so the combined split also covers whitespace - Same change in the hidden-component (dotfile) branch ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality ### Test Output ```text tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_single_space_in_dirname PASSED tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_multiple_spaces_in_dirname PASSED tests/test_learn/test_scanner.py::TestGreedyPathDecode::test_space_nested_path PASSED tests/test_learn/test_scanner.py::TestDecodeProjectPath::test_windows_path_with_spaces_decoded_via_greedy PASSED 4 passed in 0.64s ``` ## Real Behavior Proof - Environment: Windows 11 Home 10.0.26200, Python 3.10.18 - Exact command / steps: Ran `python -m pytest tests/test_learn/test_scanner.py -v` on Windows after applying the fix. Also verified `_component_tokenizations("Claude Code Projects")` returns `[['Claude Code Projects'], ['Claude', 'Code', 'Projects']]`. The integration test creates a real temp directory with spaces and asserts `_decode_project_path()` resolves it correctly. - Observed result: All 4 new tests pass on Windows. All 34 scanner tests pass. Ruff check clean. - Not tested: No manual `headroom learn --apply` end-to-end run, but the integration test exercises the same `_decode_project_path` code path with a real temp directory on disk. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes ## Additional Notes The fix follows the exact same pattern used for underscores (issue headroomlabs-ai#159) and dots (issue headroomlabs-ai#47) — extending the separator list. Spaces are the last common character that Claude Code flattens to `-` but the decoder didn't know about.
1 parent e616dcf commit 2d3701b

2 files changed

Lines changed: 50 additions & 4 deletions

File tree

headroom/learn/plugins/claude.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,19 +397,19 @@ def add(tokens: list[str]) -> None:
397397

398398
add([component])
399399

400-
for separator in ("-", ".", "_", None):
400+
for separator in (" ", "-", ".", "_", None):
401401
if separator is None:
402-
tokens = [token for token in re.split(r"[-._]", component) if token]
402+
tokens = [token for token in re.split(r"[-.\s_]", component) if token]
403403
else:
404404
tokens = [token for token in component.split(separator) if token]
405405
add(tokens)
406406

407407
if component.startswith(".") and len(component) > 1:
408408
hidden_component = component[1:]
409409
add(["", hidden_component])
410-
for separator in ("-", ".", "_", None):
410+
for separator in (" ", "-", ".", "_", None):
411411
if separator is None:
412-
tokens = [token for token in re.split(r"[-._]", hidden_component) if token]
412+
tokens = [token for token in re.split(r"[-.\s_]", hidden_component) if token]
413413
else:
414414
tokens = [token for token in hidden_component.split(separator) if token]
415415
add(["", *tokens])

tests/test_learn/test_scanner.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,26 @@ def test_hybrid_hyphen_and_dot_flattening(self, tmp_path: Path) -> None:
104104
result = _greedy_path_decode(tmp_path, ["my", "cool", "project", "nosync", "headroom"])
105105
assert result == tmp_path / "my-cool-project.nosync" / "headroom"
106106

107+
# ---- Space tests (issue #997) ----
108+
109+
def test_single_space_in_dirname(self, tmp_path: Path) -> None:
110+
"""Directory name contains a space (e.g. 'Claude Projects')."""
111+
_make_dirs(tmp_path, "Claude Projects")
112+
result = _greedy_path_decode(tmp_path, ["Claude", "Projects"])
113+
assert result == tmp_path / "Claude Projects"
114+
115+
def test_multiple_spaces_in_dirname(self, tmp_path: Path) -> None:
116+
"""Directory name contains multiple spaces (e.g. 'Claude Code Projects')."""
117+
_make_dirs(tmp_path, "Claude Code Projects")
118+
result = _greedy_path_decode(tmp_path, ["Claude", "Code", "Projects"])
119+
assert result == tmp_path / "Claude Code Projects"
120+
121+
def test_space_nested_path(self, tmp_path: Path) -> None:
122+
"""Nested path like Desktop/'Claude Code Projects' should decode correctly."""
123+
_make_dirs(tmp_path, "Desktop/Claude Code Projects")
124+
result = _greedy_path_decode(tmp_path, ["Desktop", "Claude", "Code", "Projects"])
125+
assert result == tmp_path / "Desktop" / "Claude Code Projects"
126+
107127
# ---- Underscore tests (issue #159) ----
108128

109129
def test_single_underscore_in_dirname(self, tmp_path: Path) -> None:
@@ -332,6 +352,32 @@ def test_windows_username_with_dot_stays_single_component(self) -> None:
332352
assert "john\\doe" not in rendered
333353
assert "john/doe" not in rendered
334354

355+
def test_windows_path_with_spaces_decoded_via_greedy(self) -> None:
356+
"""Spaces in Windows dir names must not split into separate components (#997).
357+
358+
Claude Code encodes 'C:\\Users\\user\\Desktop\\Claude Code Projects' as
359+
'-C-Users-user-Desktop-Claude-Code-Projects'. The greedy decoder must
360+
reconstruct 'Claude Code Projects' as a single directory.
361+
"""
362+
import sys
363+
import tempfile
364+
365+
if sys.platform != "win32":
366+
pytest.skip("greedy Windows-path decode requires real Windows filesystem")
367+
368+
with tempfile.TemporaryDirectory() as td:
369+
space_dir = Path(td) / "Claude Code Projects"
370+
space_dir.mkdir()
371+
372+
drive = Path(td).drive[0]
373+
rest = str(Path(td))[3:] # strip 'C:\\'
374+
rest_parts = rest.replace("\\", "-").replace(" ", "-")
375+
encoded = f"-{drive}-{rest_parts}-Claude-Code-Projects"
376+
377+
result = _decode_project_path(encoded)
378+
assert result is not None
379+
assert result == space_dir
380+
335381
def test_discover_windows_project_uses_leaf_name(self, tmp_path: Path) -> None:
336382
"""A syntactic Windows path decoded on Unix should still display the project leaf."""
337383
claude_dir = tmp_path / ".claude"

0 commit comments

Comments
 (0)