fix(learn): decode directory names with spaces in Windows project paths (#997) (#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 #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:
Shengbo_Wang 2026-06-16 12:29:53 +08:00 committed by GitHub
parent e616dcf788
commit 2d3701b59e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 4 deletions

View file

@ -397,9 +397,9 @@ def _component_tokenizations(component: str) -> list[list[str]]:
add([component])
for separator in ("-", ".", "_", None):
for separator in (" ", "-", ".", "_", 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:
tokens = [token for token in component.split(separator) if token]
add(tokens)
@ -407,9 +407,9 @@ def _component_tokenizations(component: str) -> list[list[str]]:
if component.startswith(".") and len(component) > 1:
hidden_component = component[1:]
add(["", hidden_component])
for separator in ("-", ".", "_", None):
for separator in (" ", "-", ".", "_", 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:
tokens = [token for token in hidden_component.split(separator) if token]
add(["", *tokens])

View file

@ -104,6 +104,26 @@ class TestGreedyPathDecode:
result = _greedy_path_decode(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) ----
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
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:
"""A syntactic Windows path decoded on Unix should still display the project leaf."""
claude_dir = tmp_path / ".claude"