mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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 #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 #159) and dots (issue #47) — extending the separator list. Spaces are the last common character that Claude Code flattens to `-` but the decoder didn't know about.
This commit is contained in:
parent
e616dcf788
commit
2d3701b59e
2 changed files with 50 additions and 4 deletions
|
|
@ -397,9 +397,9 @@ def _component_tokenizations(component: str) -> list[list[str]]:
|
||||||
|
|
||||||
add([component])
|
add([component])
|
||||||
|
|
||||||
for separator in ("-", ".", "_", None):
|
for separator in (" ", "-", ".", "_", None):
|
||||||
if separator is None:
|
if separator is None:
|
||||||
tokens = [token for token in re.split(r"[-._]", component) if token]
|
tokens = [token for token in re.split(r"[-.\s_]", component) if token]
|
||||||
else:
|
else:
|
||||||
tokens = [token for token in component.split(separator) if token]
|
tokens = [token for token in component.split(separator) if token]
|
||||||
add(tokens)
|
add(tokens)
|
||||||
|
|
@ -407,9 +407,9 @@ def _component_tokenizations(component: str) -> list[list[str]]:
|
||||||
if component.startswith(".") and len(component) > 1:
|
if component.startswith(".") and len(component) > 1:
|
||||||
hidden_component = component[1:]
|
hidden_component = component[1:]
|
||||||
add(["", hidden_component])
|
add(["", hidden_component])
|
||||||
for separator in ("-", ".", "_", None):
|
for separator in (" ", "-", ".", "_", None):
|
||||||
if separator is None:
|
if separator is None:
|
||||||
tokens = [token for token in re.split(r"[-._]", hidden_component) if token]
|
tokens = [token for token in re.split(r"[-.\s_]", hidden_component) if token]
|
||||||
else:
|
else:
|
||||||
tokens = [token for token in hidden_component.split(separator) if token]
|
tokens = [token for token in hidden_component.split(separator) if token]
|
||||||
add(["", *tokens])
|
add(["", *tokens])
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,26 @@ class TestGreedyPathDecode:
|
||||||
result = _greedy_path_decode(tmp_path, ["my", "cool", "project", "nosync", "headroom"])
|
result = _greedy_path_decode(tmp_path, ["my", "cool", "project", "nosync", "headroom"])
|
||||||
assert result == tmp_path / "my-cool-project.nosync" / "headroom"
|
assert result == tmp_path / "my-cool-project.nosync" / "headroom"
|
||||||
|
|
||||||
|
# ---- Space tests (issue #997) ----
|
||||||
|
|
||||||
|
def test_single_space_in_dirname(self, tmp_path: Path) -> None:
|
||||||
|
"""Directory name contains a space (e.g. 'Claude Projects')."""
|
||||||
|
_make_dirs(tmp_path, "Claude Projects")
|
||||||
|
result = _greedy_path_decode(tmp_path, ["Claude", "Projects"])
|
||||||
|
assert result == tmp_path / "Claude Projects"
|
||||||
|
|
||||||
|
def test_multiple_spaces_in_dirname(self, tmp_path: Path) -> None:
|
||||||
|
"""Directory name contains multiple spaces (e.g. 'Claude Code Projects')."""
|
||||||
|
_make_dirs(tmp_path, "Claude Code Projects")
|
||||||
|
result = _greedy_path_decode(tmp_path, ["Claude", "Code", "Projects"])
|
||||||
|
assert result == tmp_path / "Claude Code Projects"
|
||||||
|
|
||||||
|
def test_space_nested_path(self, tmp_path: Path) -> None:
|
||||||
|
"""Nested path like Desktop/'Claude Code Projects' should decode correctly."""
|
||||||
|
_make_dirs(tmp_path, "Desktop/Claude Code Projects")
|
||||||
|
result = _greedy_path_decode(tmp_path, ["Desktop", "Claude", "Code", "Projects"])
|
||||||
|
assert result == tmp_path / "Desktop" / "Claude Code Projects"
|
||||||
|
|
||||||
# ---- Underscore tests (issue #159) ----
|
# ---- Underscore tests (issue #159) ----
|
||||||
|
|
||||||
def test_single_underscore_in_dirname(self, tmp_path: Path) -> None:
|
def test_single_underscore_in_dirname(self, tmp_path: Path) -> None:
|
||||||
|
|
@ -332,6 +352,32 @@ class TestDecodeProjectPath:
|
||||||
assert "john\\doe" not in rendered
|
assert "john\\doe" not in rendered
|
||||||
assert "john/doe" not in rendered
|
assert "john/doe" not in rendered
|
||||||
|
|
||||||
|
def test_windows_path_with_spaces_decoded_via_greedy(self) -> None:
|
||||||
|
"""Spaces in Windows dir names must not split into separate components (#997).
|
||||||
|
|
||||||
|
Claude Code encodes 'C:\\Users\\user\\Desktop\\Claude Code Projects' as
|
||||||
|
'-C-Users-user-Desktop-Claude-Code-Projects'. The greedy decoder must
|
||||||
|
reconstruct 'Claude Code Projects' as a single directory.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
if sys.platform != "win32":
|
||||||
|
pytest.skip("greedy Windows-path decode requires real Windows filesystem")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
space_dir = Path(td) / "Claude Code Projects"
|
||||||
|
space_dir.mkdir()
|
||||||
|
|
||||||
|
drive = Path(td).drive[0]
|
||||||
|
rest = str(Path(td))[3:] # strip 'C:\\'
|
||||||
|
rest_parts = rest.replace("\\", "-").replace(" ", "-")
|
||||||
|
encoded = f"-{drive}-{rest_parts}-Claude-Code-Projects"
|
||||||
|
|
||||||
|
result = _decode_project_path(encoded)
|
||||||
|
assert result is not None
|
||||||
|
assert result == space_dir
|
||||||
|
|
||||||
def test_discover_windows_project_uses_leaf_name(self, tmp_path: Path) -> None:
|
def test_discover_windows_project_uses_leaf_name(self, tmp_path: Path) -> None:
|
||||||
"""A syntactic Windows path decoded on Unix should still display the project leaf."""
|
"""A syntactic Windows path decoded on Unix should still display the project leaf."""
|
||||||
claude_dir = tmp_path / ".claude"
|
claude_dir = tmp_path / ".claude"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue