From 2d3701b59e9ff8aedc2a282c4467f27ca2355d62 Mon Sep 17 00:00:00 2001 From: Shengbo_Wang <100296216+RTCartist@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:29:53 +0800 Subject: [PATCH] fix(learn): decode directory names with spaces in Windows project paths (#997) (#1027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- headroom/learn/plugins/claude.py | 8 +++--- tests/test_learn/test_scanner.py | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/headroom/learn/plugins/claude.py b/headroom/learn/plugins/claude.py index 2be4f9e7e..37f7e3434 100644 --- a/headroom/learn/plugins/claude.py +++ b/headroom/learn/plugins/claude.py @@ -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]) diff --git a/tests/test_learn/test_scanner.py b/tests/test_learn/test_scanner.py index 6e0efbeb8..0f6dcb9f1 100644 --- a/tests/test_learn/test_scanner.py +++ b/tests/test_learn/test_scanner.py @@ -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"