On this page
Move session data after a project directory rename.
#claudewheel.mv
#claudewheel.mv
Move session data after a project directory rename.
#MvResult
Counters tracking the outcome of a project-directory move operation.
#_discover_profile_dirs
def _discover_profile_dirs(ws: 'Workspace') -> list[Path]Find all profile directories plus ~/.claudewheel/shared/ if it exists.
Enumerates profiles via the workspace's ProfileStore, then includes the shared store directory as a peer target (it holds the actual session data). A corrupt token entry raises TokenStoreError -- the uniform hard-error contract.
#_rewrite_jsonl_file
def _rewrite_jsonl_file(path: Path, old_path: str, new_path: str, dry_run: bool) -> intReplace old_path with new_path in every line of a JSONL file.
Returns the number of lines where a replacement was made.
#_plan_migrations
def _plan_migrations(old_resolved: str, new_resolved: str, descendants: set[str]) -> list[tuple[str, str]]Build the ordered (old, new) migration plan.
Includes old_resolved itself. Every destination is new_resolved plus the source's relative suffix. Longest old paths come first so a shorter prefix is never processed before its own descendants (prefix-shadowing prevention, same pattern as import_'s rewriters).
#_decode_rel
def _decode_rel(root: Path, enc: str) -> list[str]Find every existing relative dir path under root whose encoding is enc.
The path encoding is lossy ('/', '.', '_' and literal '-' all become '-'), so one encoded string can correspond to several real paths. All matches are returned so the caller can detect ambiguity.
#_read_claude_json
def _read_claude_json(path: Path) -> dict[str, Any]Read one profile's .claude.json, hard-erroring when it cannot be read.
Swallowing an unreadable registry is not an option here: during discovery it turns decodable descendants into spurious "undecodable orphan" errors that name the wrong cause, and during the update pass it lets the migration report success while leaving that profile's projects{} at the old path. Both readers refuse instead, per the module's uniform hard-error contract.
#_collect_project_keys
def _collect_project_keys(profile_dirs: list[Path], shared_dir: Path) -> set[str]All real-path keys under projects{} across every profile's .claude.json.
An unreadable or malformed .claude.json is a hard error (see _read_claude_json), not a profile silently contributing zero keys.
#_discover_descendants
def _discover_descendants(profile_dirs: list[Path], old_resolved: str, source_root: Path, known_keys: set[str]) -> set[str]Every real project path equal to or under old_resolved that has data.
Union of (a) .claude.json projects{} keys under OLD and (b) encoded projects/ dir names that decode to a path under OLD. Encoded names are never prefix-matched directly -- the encoding is ambiguous -- so each candidate is resolved back to a real path via the known keys plus filesystem checks under the moved tree. A candidate that resolves to a sibling path (merely sharing the encoded prefix) is skipped; one that cannot be resolved to exactly one real path is a hard error.
#_verify_destinations
def _verify_destinations(migrations: list[tuple[str, str]], old_resolved: str, source_root: Path, new_resolved: str) -> NoneHard-error unless every descendant's destination will exist on disk.
source_root is the moved tree as it currently exists (OLD before the rename, NEW in post-hoc mode), so source_root/<suffix> existing now is equivalent to NEW/<suffix> existing at migration time. On failure, every unresolvable descendant is listed and nothing is migrated.
#_rename_project_dir
def _rename_project_dir(old_project: Path, new_project: Path, dry_run: bool) -> boolRename old_project to new_project, merging when the target exists.
Returns True when a rename or merge happened (or would happen in dry run).
#_rewrite_prefixed_path
def _rewrite_prefixed_path(path: str, migrations: list[tuple[str, str]]) -> strRewrite a real path equal to or under a migrated source path.
migrations is longest-source-first, so the most specific mapping wins.
#_update_claude_json
def _update_claude_json(path: Path, migrations: list[tuple[str, str]], dry_run: bool) -> tuple[int, int]Rename project keys and rewrite githubRepoPaths in one .claude.json.
Project keys live under data["projects"]; every key matching a migration source is renamed to its destination. githubRepoPaths values (repo -> list of local paths) equal to or under a migration source are rewritten too. Returns (project_keys_updated, github_paths_updated).
An unreadable or malformed file is a hard error (see _read_claude_json): returning (0, 0) would let the migration report success while this profile's registry still points at the old path.
#run_mv
def run_mv(ws: 'Workspace', old_path: str, new_path: str, dry_run: bool=False, quiet: bool=False, post_hoc: bool=False) -> MvResultRename a project directory and migrate Claude Code session data.
In default mode, renames old_path to new_path on the filesystem and then migrates all session data. With post_hoc=True, skips the filesystem rename (the directory was already renamed externally) and only migrates sessions.
The migration is prefix-aware: every project keyed at old_path or nested under it (Claude Code projects inside the moved tree) is migrated to new_path plus the same relative suffix. That covers the encoded projects/ dirs, the projects{} keys and githubRepoPaths entries in every profile's .claude.json, and the JSONL cwd references of every migrated project. Each descendant's destination must exist on disk under new_path; otherwise the operation aborts before touching anything.