mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(learn): decode Windows drive-style project dirs with dotted usernames (#1855)
## Description Fixes #1849. On Windows, `headroom learn --all --apply` failed to write recommendations for every project when the username contains a dot (e.g. `pradipe.yoggi`), reporting `[WinError 161] The specified path is invalid: '\\\Users\...'`. Root cause: Claude Code encodes `C:\Users\first.last\proj` as `C--Users-first-last-proj` — **no leading dash** (the path starts with the drive letter), and `:` + `\` each collapse to `-`, producing a double dash after the drive letter. Two defects followed: 1. `_decode_project_path()` required `escaped_name.startswith("-")` and returned `None` for every real Windows encoding, so the greedy filesystem-walking decoder (which correctly rejoins dotted components like `first.last`) was unreachable. 2. The `discover_projects()` fallback blindly stripped the first character (`entry.name[1:]`), turning `C--Users-...` into `--Users-...`, whose dash→slash replacement yields the invalid `\\\Users\first\last\proj` seen in the issue. ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Refactoring (no functional changes) ## Changes Made - `headroom/learn/plugins/claude.py` - New `_decode_windows_path(drive, parts)` helper: drops empty split tokens (so separators are never doubled), checks the literal path, greedy-decodes from the drive root (so `Users` → `first.last` is rejoined from the real filesystem via the existing `_component_tokenizations` dot-split), and keeps the trust-`Users` literal fallback. - `_decode_project_path()` now matches both the real drive-style encoding `C--Users-...` (no leading dash) and the legacy `-C-Users-...` form via `^-?([A-Za-z])--?(.+)$`, routing both through the helper; POSIX logic unchanged. - `discover_projects()` fallback applies the same normalization instead of stripping the first character, so nonexistent projects still get a *valid* `C:\Users\...` path instead of `\\\Users\...`. - `tests/test_learn/test_scanner.py`: three new tests — double-dash encoding decodes without doubled separators; dotted username rejoined via greedy decode on a real directory tree (Windows-only); `discover_projects` fallback produces a valid path for a nonexistent `C--Users-...` project. ## Testing - [x] Existing tests pass locally - [x] Added new tests covering the change ``` $ python -m pytest tests/test_learn -q 3 failed, 211 passed, 5 skipped in 7.22s # The 3 failures (test_home_dir_username_stays_single_component, # test_includes_project_info, test_double_write_replaces_not_appends) are # pre-existing Windows-local failures, verified identical on a clean # upstream/main checkout via git stash — none introduced by this change. $ ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py && ruff format --check ... All checks passed! $ mypy headroom --ignore-missing-imports Success: no issues found ``` ## Real Behavior Proof - Environment: Windows 11 Pro, PowerShell, Python 3.14, headroom built from this branch (Rust core built locally) - Exact command / steps: `python -c "from headroom.learn.plugins.claude import _decode_project_path as d; print(d('G--Programmi-Aggiuntivi-headroom')); print(d('C--Users-esiri-AppData-Local-Temp'))"` — decoding this machine's own real `~/.claude/projects` directory names (which use the drive-style encoding this PR fixes; note `Programmi Aggiuntivi` contains a space, exercising the greedy multi-token rejoin just like a dotted username) - Observed result: `G:\Programmi Aggiuntivi\headroom` and `C:\Users\esiri\AppData\Local\Temp` — both correct real paths. On upstream/main the same call returns `None` for both, which is what pushed `learn --all` into the mangling fallback. - Not tested: an actual Active Directory `first.last` account end-to-end (no such account available); covered instead by the Windows-only greedy-decode test against a real `john.doe` directory tree and by the space-in-path live decode above, which exercises the identical code path. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
140d6e4f96
commit
4f22cbb05c
2 changed files with 90 additions and 18 deletions
|
|
@ -70,12 +70,14 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner):
|
|||
|
||||
project_path = _decode_project_path(entry.name)
|
||||
if project_path is None:
|
||||
fallback_parts = entry.name[1:].split("-")
|
||||
if len(fallback_parts[0]) == 1 and fallback_parts[0].isalpha():
|
||||
drive = fallback_parts[0].upper()
|
||||
project_path = Path(f"{drive}:\\" + "\\".join(fallback_parts[1:]))
|
||||
win = re.match(r"^-?([A-Za-z])--?(.+)$", entry.name)
|
||||
if win:
|
||||
drive = win.group(1).upper()
|
||||
tokens = [p for p in win.group(2).split("-") if p]
|
||||
project_path = Path(f"{drive}:\\" + "\\".join(tokens))
|
||||
else:
|
||||
project_path = Path("/" + entry.name[1:].replace("-", "/"))
|
||||
stripped = entry.name.lstrip("-")
|
||||
project_path = Path("/" + stripped.replace("-", "/"))
|
||||
|
||||
name = _project_display_name(project_path, entry.name)
|
||||
|
||||
|
|
@ -335,8 +337,41 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner):
|
|||
# =============================================================================
|
||||
|
||||
|
||||
def _decode_windows_path(drive: str, parts: list[str]) -> Path | None:
|
||||
"""Reconstruct a Windows path from drive letter + dash-split tokens.
|
||||
|
||||
Empty tokens (from consecutive dashes in the encoded name) are dropped so
|
||||
the literal join never produces doubled separators.
|
||||
"""
|
||||
tokens = [p for p in parts if p]
|
||||
if not tokens:
|
||||
return None
|
||||
win_path = Path(f"{drive}:\\" + "\\".join(tokens))
|
||||
if win_path.exists():
|
||||
return win_path
|
||||
drive_root = Path(f"{drive}:\\")
|
||||
if drive_root.exists():
|
||||
result = _greedy_path_decode(drive_root, tokens)
|
||||
if result:
|
||||
return result
|
||||
if tokens[0].lower() == "users":
|
||||
return win_path
|
||||
return None
|
||||
|
||||
|
||||
def _decode_project_path(escaped_name: str) -> Path | None:
|
||||
"""Decode a Claude Code escaped project path."""
|
||||
# Windows paths are encoded without a leading dash: "C:\Users\x" becomes
|
||||
# "C--Users-x" (":" and "\" each collapse to "-"). Older callers also pass
|
||||
# the legacy "-C-Users-x" form; accept both.
|
||||
win = re.match(r"^-?([A-Za-z])--?(.+)$", escaped_name)
|
||||
if win:
|
||||
result = _decode_windows_path(win.group(1).upper(), win.group(2).split("-"))
|
||||
if result is not None:
|
||||
return result
|
||||
if not escaped_name.startswith("-"):
|
||||
return None
|
||||
|
||||
if not escaped_name.startswith("-"):
|
||||
return None
|
||||
|
||||
|
|
@ -344,19 +379,6 @@ def _decode_project_path(escaped_name: str) -> Path | None:
|
|||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
if len(parts[0]) == 1 and parts[0].isalpha():
|
||||
drive = parts[0].upper()
|
||||
win_path = Path(f"{drive}:\\" + "\\".join(parts[1:]))
|
||||
if win_path.exists():
|
||||
return win_path
|
||||
win_base = Path(f"{drive}:\\{parts[1]}") if len(parts) > 1 else win_path
|
||||
if win_base.exists() and len(parts) > 2:
|
||||
result = _greedy_path_decode(win_base, parts[2:])
|
||||
if result:
|
||||
return result
|
||||
if len(parts) > 1 and parts[1].lower() == "users":
|
||||
return win_path
|
||||
|
||||
simple = Path("/" + escaped_name[1:].replace("-", "/"))
|
||||
if simple.exists():
|
||||
return simple
|
||||
|
|
|
|||
|
|
@ -391,6 +391,56 @@ class TestDecodeProjectPath:
|
|||
assert projects[0].name == "work"
|
||||
assert str(projects[0].project_path).startswith("C:")
|
||||
|
||||
def test_windows_double_dash_encoding_decodes(self) -> None:
|
||||
"""Real Claude Code encoding has no leading dash: C:\\Users\\x → C--Users-x (#1849).
|
||||
|
||||
The drive colon and first backslash each flatten to '-', producing a
|
||||
double dash after the drive letter. The decoder must not emit doubled
|
||||
path separators from the resulting empty split token.
|
||||
"""
|
||||
result = _decode_project_path("C--Users-jane-proj")
|
||||
|
||||
assert result is not None
|
||||
rendered = str(result)
|
||||
assert rendered.startswith("C:")
|
||||
assert "\\\\" not in rendered.removeprefix("C:")
|
||||
assert rendered == "C:\\Users\\jane\\proj"
|
||||
|
||||
def test_windows_double_dash_dotted_username_via_greedy(self) -> None:
|
||||
"""C--...-first-last-... must rejoin 'first.last' when the dir exists (#1849)."""
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
if sys.platform != "win32":
|
||||
pytest.skip("greedy Windows-path decode requires real Windows filesystem")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
project = Path(td) / "john.doe" / "work"
|
||||
project.mkdir(parents=True)
|
||||
|
||||
drive = Path(td).drive[0]
|
||||
rest = str(project)[3:] # strip 'C:\\'
|
||||
encoded = f"{drive}--" + rest.replace("\\", "-").replace(".", "-").replace(" ", "-")
|
||||
|
||||
result = _decode_project_path(encoded)
|
||||
assert result == project
|
||||
|
||||
def test_discover_double_dash_windows_project_fallback(self, tmp_path: Path) -> None:
|
||||
"""Nonexistent C--Users-... project must fall back to a valid path, not \\\\\\Users (#1849)."""
|
||||
claude_dir = tmp_path / ".claude"
|
||||
project_dir = claude_dir / "projects" / "C--Users-jane-proj"
|
||||
project_dir.mkdir(parents=True)
|
||||
(project_dir / "session.jsonl").write_text("{}\n")
|
||||
|
||||
projects = ClaudeCodeScanner(claude_dir=claude_dir).discover_projects()
|
||||
|
||||
assert len(projects) == 1
|
||||
assert projects[0].name == "proj"
|
||||
rendered = str(projects[0].project_path)
|
||||
assert rendered.startswith("C:")
|
||||
assert "\\\\" not in rendered.removeprefix("C:")
|
||||
assert not rendered.startswith("\\")
|
||||
|
||||
def test_home_dir_username_stays_single_component(self) -> None:
|
||||
"""A home-directory name must survive decoding as one component.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue