Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ $ uvx --from 'vcspull' --prerelease allow vcspull

_Upcoming changes will be written here._

### Development

#### PrivatePath centralizes home-directory redaction (#485)

- Introduced a dedicated `PrivatePath` helper for all CLI logging and structured
output, ensuring tilde-collapsed paths stay consistent without duplicating the
contraction logic across commands.

## vcspull v1.46.1 (2025-11-03)

### Bug Fixes
Expand Down
1 change: 1 addition & 0 deletions docs/api/internals/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ If you need an internal API stabilized please [file an issue](https://github.com

```{toctree}
config_reader
private_path
```
55 changes: 55 additions & 0 deletions docs/api/internals/private_path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# PrivatePath – `vcspull._internal.private_path`

:::{warning}
`PrivatePath` is an internal helper. Its import path and behavior may change
without notice. File an issue if you rely on it downstream so we can discuss a
supported API.
:::

`PrivatePath` subclasses `pathlib.Path` and normalizes every textual rendering
(`str()`/`repr()`) so the current user’s home directory is collapsed to `~`.
The class behaves exactly like the standard path object for filesystem ops; it
only alters how the path is displayed. This keeps CLI logs, JSON/NDJSON output,
and tests from leaking usernames while preserving full absolute paths for
internal logic.

```python
from vcspull._internal.private_path import PrivatePath

home_repo = PrivatePath("~/code/vcspull")
print(home_repo) # -> ~/code/vcspull
print(repr(home_repo)) # -> "PrivatePath('~/code/vcspull')"
```

## Usage guidelines

- Wrap any path destined for user-facing output (logs, console tables, JSON
payloads) in `PrivatePath` before calling `str()`.
- The helper is safe to instantiate with `pathlib.Path` objects or strings; it
does not touch relative paths that lack a home prefix.
- Prefer storing raw `pathlib.Path` objects (or strings) in configuration
models, then convert to `PrivatePath` at the presentation layer. This keeps
serialization and equality checks deterministic while still masking the home
directory when needed.

## Why not `contract_user_home`?

The previous `contract_user_home()` helper duplicated the tilde-collapsing logic
in multiple modules and required callers to remember to run it themselves. By
centralizing the behavior in a `pathlib.Path` subclass we get:

- Built-in protection—`str()` and `repr()` automatically apply the privacy
filter.
- Consistent behavior across every CLI command and test fixture.
- Easier mocking in tests, because `PrivatePath` respects monkeypatched
`Path.home()` implementations.

If you need alternative redaction behavior, consider composing your own helper
around `PrivatePath` instead of reintroducing ad hoc string munging.

```{eval-rst}
.. automodule:: vcspull._internal.private_path
:members:
:show-inheritance:
:undoc-members:
```
69 changes: 69 additions & 0 deletions src/vcspull/_internal/private_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

import os
import pathlib
import typing as t

if t.TYPE_CHECKING:
PrivatePathBase = pathlib.Path
else:
PrivatePathBase = type(pathlib.Path())


class PrivatePath(PrivatePathBase):
"""Path subclass that hides the user's home directory in textual output.

The class behaves like :class:`pathlib.Path`, but normalizes string and
representation output to replace the current user's home directory with
``~``. This is useful when logging or displaying paths that should not leak
potentially sensitive information.

Examples
--------
>>> from pathlib import Path
>>> home = Path.home()
>>> PrivatePath(home)
PrivatePath('~')
>>> PrivatePath(home / "projects" / "vcspull")
PrivatePath('~/projects/vcspull')
>>> str(PrivatePath("/tmp/example"))
'/tmp/example'
>>> f'build dir: {PrivatePath(home / "build")}'
'build dir: ~/build'
>>> '{}'.format(PrivatePath(home / 'notes.txt'))
'~/notes.txt'
"""

def __new__(cls, *args: t.Any, **kwargs: t.Any) -> PrivatePath:
return super().__new__(cls, *args, **kwargs)

@classmethod
def _collapse_home(cls, value: str) -> str:
"""Collapse the user's home directory to ``~`` in ``value``."""
if value.startswith("~"):
return value

home = str(pathlib.Path.home())
if value == home:
return "~"

separators = {os.sep}
if os.altsep:
separators.add(os.altsep)

for sep in separators:
home_with_sep = home + sep
if value.startswith(home_with_sep):
return "~" + value[len(home) :]

return value

def __str__(self) -> str:
original = pathlib.Path.__str__(self)
return self._collapse_home(original)

def __repr__(self) -> str:
return f"{self.__class__.__name__}({str(self)!r})"


__all__ = ["PrivatePath"]
45 changes: 32 additions & 13 deletions src/vcspull/cli/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from colorama import Fore, Style

from vcspull._internal.config_reader import DuplicateAwareConfigReader
from vcspull._internal.private_path import PrivatePath
from vcspull.config import (
canonicalize_workspace_path,
expand_dir,
Expand All @@ -21,7 +22,6 @@
save_config_yaml_with_items,
workspace_root_label,
)
from vcspull.util import contract_user_home

if t.TYPE_CHECKING:
import argparse
Expand Down Expand Up @@ -220,11 +220,11 @@ def handle_add_command(args: argparse.Namespace) -> None:
repo_path = expand_dir(pathlib.Path(repo_input), cwd=cwd)

if not repo_path.exists():
log.error("Repository path %s does not exist.", repo_path)
log.error("Repository path %s does not exist.", PrivatePath(repo_path))
return

if not repo_path.is_dir():
log.error("Repository path %s is not a directory.", repo_path)
log.error("Repository path %s is not a directory.", PrivatePath(repo_path))
return

override_name = getattr(args, "override_name", None)
Expand All @@ -238,7 +238,7 @@ def handle_add_command(args: argparse.Namespace) -> None:
display_url, config_url = _normalize_detected_url(detected_remote)

if not config_url:
display_url = contract_user_home(repo_path)
display_url = str(PrivatePath(repo_path))
config_url = str(repo_path)
log.warning(
"Unable to determine git remote for %s; using local path in config.",
Expand All @@ -262,7 +262,7 @@ def handle_add_command(args: argparse.Namespace) -> None:

summary_url = display_url or config_url

display_path = contract_user_home(repo_path)
display_path = str(PrivatePath(repo_path))

log.info("%sFound new repository to import:%s", Fore.GREEN, Style.RESET_ALL)
log.info(
Expand Down Expand Up @@ -319,7 +319,11 @@ def handle_add_command(args: argparse.Namespace) -> None:
response = ""
proceed = response.strip().lower() in {"y", "yes"}
if not proceed:
log.info("Aborted import of '%s' from %s", repo_name, repo_path)
log.info(
"Aborted import of '%s' from %s",
repo_name,
PrivatePath(repo_path),
)
return

add_repo(
Expand Down Expand Up @@ -370,7 +374,7 @@ def add_repo(
config_file_path = pathlib.Path.cwd() / ".vcspull.yaml"
log.info(
"No config specified and no default found, will create at %s",
contract_user_home(config_file_path),
PrivatePath(config_file_path),
)
elif len(home_configs) > 1:
log.error(
Expand All @@ -384,7 +388,7 @@ def add_repo(
raw_config: dict[str, t.Any]
duplicate_root_occurrences: dict[str, list[t.Any]]
top_level_items: list[tuple[str, t.Any]]
display_config_path = contract_user_home(config_file_path)
display_config_path = str(PrivatePath(config_file_path))

if config_file_path.exists() and config_file_path.is_file():
try:
Expand All @@ -400,7 +404,10 @@ def add_repo(
)
return
except Exception:
log.exception("Error loading YAML from %s. Aborting.", config_file_path)
log.exception(
"Error loading YAML from %s. Aborting.",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return
Expand Down Expand Up @@ -579,7 +586,10 @@ def _prepare_no_merge_items(
Style.RESET_ALL,
)
except Exception:
log.exception("Error saving config to %s", config_file_path)
log.exception(
"Error saving config to %s",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
elif (duplicate_merge_changes > 0 or config_was_relabelled) and dry_run:
Expand Down Expand Up @@ -635,7 +645,10 @@ def _prepare_no_merge_items(
Style.RESET_ALL,
)
except Exception:
log.exception("Error saving config to %s", config_file_path)
log.exception(
"Error saving config to %s",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return
Expand Down Expand Up @@ -719,7 +732,10 @@ def _prepare_no_merge_items(
Style.RESET_ALL,
)
except Exception:
log.exception("Error saving config to %s", config_file_path)
log.exception(
"Error saving config to %s",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return
Expand Down Expand Up @@ -778,6 +794,9 @@ def _prepare_no_merge_items(
Style.RESET_ALL,
)
except Exception:
log.exception("Error saving config to %s", config_file_path)
log.exception(
"Error saving config to %s",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
16 changes: 13 additions & 3 deletions src/vcspull/cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from colorama import Fore, Style

from vcspull._internal.config_reader import DuplicateAwareConfigReader
from vcspull._internal.private_path import PrivatePath
from vcspull.config import (
canonicalize_workspace_path,
expand_dir,
Expand Down Expand Up @@ -212,7 +213,10 @@ def discover_repos(
)
return
except Exception:
log.exception("Error loading YAML from %s. Aborting.", config_file_path)
log.exception(
"Error loading YAML from %s. Aborting.",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return
Expand Down Expand Up @@ -458,7 +462,10 @@ def discover_repos(
Style.RESET_ALL,
)
except Exception:
log.exception("Error saving config to %s", config_file_path)
log.exception(
"Error saving config to %s",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return
Expand Down Expand Up @@ -551,7 +558,10 @@ def discover_repos(
Style.RESET_ALL,
)
except Exception:
log.exception("Error saving config to %s", config_file_path)
log.exception(
"Error saving config to %s",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return
Expand Down
16 changes: 13 additions & 3 deletions src/vcspull/cli/fmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from colorama import Fore, Style

from vcspull._internal.config_reader import DuplicateAwareConfigReader
from vcspull._internal.private_path import PrivatePath
from vcspull.config import (
find_config_files,
find_home_config_files,
Expand Down Expand Up @@ -176,10 +177,16 @@ def format_single_config(
DuplicateAwareConfigReader.load_with_duplicates(config_file_path)
)
except TypeError:
log.exception("Config file %s is not a mapping", config_file_path)
log.exception(
"Config file %s is not a mapping",
PrivatePath(config_file_path),
)
return False
except Exception:
log.exception("Error loading config from %s", config_file_path)
log.exception(
"Error loading config from %s",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return False
Expand Down Expand Up @@ -359,7 +366,10 @@ def format_single_config(
Style.RESET_ALL,
)
except Exception:
log.exception("Error saving formatted config to %s", config_file_path)
log.exception(
"Error saving formatted config to %s",
PrivatePath(config_file_path),
)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return False
Expand Down
Loading