diff --git a/CHANGES b/CHANGES index 318b3464..9d4d4b74 100644 --- a/CHANGES +++ b/CHANGES @@ -31,7 +31,16 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force -_Notes on upcoming releases will be added here_ +### Improvements + +#### Improved home directories paths (#472) + +- **Human-readable output**: Contract `$HOME` to `~/` in introspection commands + - `vcspull list`, `vcspull status`, and `vcspull sync --dry-run` now display + paths like `~/code/flask` instead of `/home/username/code/flask` + - Reduces verbosity and avoids exposing usernames in terminal output + - JSON/NDJSON output preserves full absolute paths for accuracy in automation +- **Documentation**: Updated all examples to use `~/` for consistency ## vcspull v1.39.0 (2025-10-19) diff --git a/docs/cli/list.md b/docs/cli/list.md index 600c6e37..66d76e01 100644 --- a/docs/cli/list.md +++ b/docs/cli/list.md @@ -23,9 +23,9 @@ List all configured repositories: ```console $ vcspull list -• tiktoken → /home/d/study/ai/tiktoken -• GeographicLib → /home/d/study/c++/GeographicLib -• flask → /home/d/code/flask +• tiktoken → ~/study/ai/tiktoken +• GeographicLib → ~/study/c++/GeographicLib +• flask → ~/code/flask ``` ## Filtering repositories @@ -34,8 +34,8 @@ Filter repositories using fnmatch-style patterns: ```console $ vcspull list 'flask*' -• flask → /home/d/code/flask -• flask-sqlalchemy → /home/d/code/flask-sqlalchemy +• flask → ~/code/flask +• flask-sqlalchemy → ~/code/flask-sqlalchemy ``` Multiple patterns are supported: @@ -52,14 +52,14 @@ Group repositories by workspace root with `--tree`: $ vcspull list --tree ~/study/ai/ - • tiktoken → /home/d/study/ai/tiktoken + • tiktoken → ~/study/ai/tiktoken ~/study/c++/ - • GeographicLib → /home/d/study/c++/GeographicLib - • anax → /home/d/study/c++/anax + • GeographicLib → ~/study/c++/GeographicLib + • anax → ~/study/c++/anax ~/code/ - • flask → /home/d/code/flask + • flask → ~/code/flask ``` ## JSON output @@ -77,13 +77,13 @@ Output format: { "name": "tiktoken", "url": "git+https://github.com/openai/tiktoken.git", - "path": "/home/d/study/ai/tiktoken", + "path": "~/study/ai/tiktoken", "workspace_root": "~/study/ai/" }, { "name": "flask", "url": "git+https://github.com/pallets/flask.git", - "path": "/home/d/code/flask", + "path": "~/code/flask", "workspace_root": "~/code/" } ] @@ -104,8 +104,8 @@ For streaming and line-oriented processing, use `--ndjson`: ```console $ vcspull list --ndjson -{"name":"tiktoken","url":"git+https://github.com/openai/tiktoken.git","path":"/home/d/study/ai/tiktoken","workspace_root":"~/study/ai/"} -{"name":"flask","url":"git+https://github.com/pallets/flask.git","path":"/home/d/code/flask","workspace_root":"~/code/"} +{"name":"tiktoken","url":"git+https://github.com/openai/tiktoken.git","path":"~/study/ai/tiktoken","workspace_root":"~/study/ai/"} +{"name":"flask","url":"git+https://github.com/pallets/flask.git","path":"~/code/flask","workspace_root":"~/code/"} ``` Each line is a complete JSON object, making it ideal for: @@ -134,8 +134,8 @@ Filter repositories by workspace root with `-w/--workspace/--workspace-root`: ```console $ vcspull list -w ~/code/ -• flask → /home/d/code/flask -• requests → /home/d/code/requests +• flask → ~/code/flask +• requests → ~/code/requests ``` Globbing is supported, so you can target multiple related workspaces: diff --git a/docs/cli/status.md b/docs/cli/status.md index ac90e38e..e8a352bd 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -42,8 +42,8 @@ Filter repositories using fnmatch-style patterns: ```console $ vcspull status 'django*' -• django → /home/d/code/django (exists, clean) -• django-extensions → /home/d/code/django-extensions (missing) +• django → ~/code/django (exists, clean) +• django-extensions → ~/code/django-extensions (missing) ``` Multiple patterns are supported: @@ -59,7 +59,7 @@ Show additional information with `--detailed` or `-d`: ```console $ vcspull status --detailed ✓ flask: up to date - Path: /home/d/code/flask + Path: ~/code/flask Branch: main Ahead/Behind: 0/0 ``` @@ -84,7 +84,7 @@ Output format: { "reason": "status", "name": "tiktoken", - "path": "/home/d/study/ai/tiktoken", + "path": "~/study/ai/tiktoken", "workspace_root": "~/study/ai/", "exists": false, "is_git": false, @@ -96,7 +96,7 @@ Output format: { "reason": "status", "name": "flask", - "path": "/home/d/code/flask", + "path": "~/code/flask", "workspace_root": "~/code/", "exists": true, "is_git": true, @@ -140,8 +140,8 @@ For streaming output, use `--ndjson`: ```console $ vcspull status --ndjson -{"reason":"status","name":"tiktoken","path":"/home/d/study/ai/tiktoken","workspace_root":"~/study/ai/","exists":false,"is_git":false,"clean":null} -{"reason":"status","name":"flask","path":"/home/d/code/flask","workspace_root":"~/code/","exists":true,"is_git":true,"clean":true} +{"reason":"status","name":"tiktoken","path":"~/study/ai/tiktoken","workspace_root":"~/study/ai/","exists":false,"is_git":false,"clean":null} +{"reason":"status","name":"flask","path":"~/code/flask","workspace_root":"~/code/","exists":true,"is_git":true,"clean":true} {"reason":"summary","total":2,"exists":1,"missing":1,"clean":1,"dirty":0} ``` diff --git a/docs/cli/sync.md b/docs/cli/sync.md index 9131296f..2e219e52 100644 --- a/docs/cli/sync.md +++ b/docs/cli/sync.md @@ -25,9 +25,9 @@ Preview what would be synchronized without making changes: ```console $ vcspull sync --dry-run '*' -Would sync flask at /home/d/code/flask -Would sync django at /home/d/code/django -Would sync requests at /home/d/code/requests +Would sync flask at ~/code/flask +Would sync django at ~/code/django +Would sync requests at ~/code/requests ``` Use `--dry-run` or `-n` to: @@ -46,7 +46,7 @@ $ vcspull sync --dry-run --json '*' { "reason": "sync", "name": "flask", - "path": "/home/d/code/flask", + "path": "~/code/flask", "workspace_root": "~/code/", "status": "preview" }, @@ -75,7 +75,7 @@ Stream sync events line-by-line with `--ndjson`: ```console $ vcspull sync --dry-run --ndjson '*' -{"reason":"sync","name":"flask","path":"/home/d/code/flask","workspace_root":"~/code/","status":"preview"} +{"reason":"sync","name":"flask","path":"~/code/flask","workspace_root":"~/code/","status":"preview"} {"reason":"summary","total":3,"synced":0,"previewed":3,"failed":0} ``` diff --git a/src/vcspull/cli/list.py b/src/vcspull/cli/list.py index 60b326a3..d44cc5ee 100644 --- a/src/vcspull/cli/list.py +++ b/src/vcspull/cli/list.py @@ -8,6 +8,7 @@ import typing as t from vcspull.config import filter_repos, find_config_files, load_configs +from vcspull.util import contract_user_home from ._colors import Colors, get_color_mode from ._output import OutputFormatter, get_output_mode @@ -170,10 +171,10 @@ def _output_flat( } ) - # Human output + # Human output (contract home directory for privacy/brevity) formatter.emit_text( f"{colors.muted('•')} {colors.info(repo_name)} " - f"{colors.muted('→')} {repo_path}", + f"{colors.muted('→')} {contract_user_home(repo_path)}", ) @@ -221,8 +222,8 @@ def _output_tree( } ) - # Human output: indented repo + # Human output: indented repo (contract home directory for privacy/brevity) formatter.emit_text( f" {colors.muted('•')} {colors.info(repo_name)} " - f"{colors.muted('→')} {repo_path}", + f"{colors.muted('→')} {contract_user_home(repo_path)}", ) diff --git a/src/vcspull/cli/status.py b/src/vcspull/cli/status.py index 692eda15..0c44aaab 100644 --- a/src/vcspull/cli/status.py +++ b/src/vcspull/cli/status.py @@ -9,6 +9,7 @@ import typing as t from vcspull.config import filter_repos, find_config_files, load_configs +from vcspull.util import contract_user_home from ._colors import Colors, get_color_mode from ._output import OutputFormatter, get_output_mode @@ -344,7 +345,9 @@ def _format_status_line( formatter.emit_text(f"{symbol} {colors.info(name)}: {status_color}") if detailed: - formatter.emit_text(f" {colors.muted('Path:')} {status['path']}") + formatter.emit_text( + f" {colors.muted('Path:')} {contract_user_home(status['path'])}" + ) branch = status.get("branch") if branch: formatter.emit_text(f" {colors.muted('Branch:')} {branch}") diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py index 640777d1..d893d12b 100644 --- a/src/vcspull/cli/sync.py +++ b/src/vcspull/cli/sync.py @@ -25,6 +25,7 @@ from vcspull import exc from vcspull.config import filter_repos, find_config_files, load_configs from vcspull.types import ConfigDict +from vcspull.util import contract_user_home from ._colors import Colors, get_color_mode from ._output import ( @@ -433,6 +434,9 @@ def _render_plan( display_path = str(rel_path) except ValueError: display_path = entry.path + else: + # Contract home directory for privacy/brevity in human output + display_path = contract_user_home(display_path) detail_text = _format_detail_text( entry, diff --git a/src/vcspull/util.py b/src/vcspull/util.py index 74496042..642a9a4c 100644 --- a/src/vcspull/util.py +++ b/src/vcspull/util.py @@ -42,6 +42,40 @@ def get_config_dir() -> pathlib.Path: return path +def contract_user_home(path: str | pathlib.Path) -> str: + """Contract user home directory to ~ for display purposes. + + Parameters + ---------- + path : str | pathlib.Path + Path to contract + + Returns + ------- + str + Path with $HOME contracted to ~ + + Examples + -------- + >>> contract_user_home("/home/user/code/repo") + '~/code/repo' + >>> contract_user_home("/opt/project") + '/opt/project' + """ + path_str = str(path) + home_str = str(pathlib.Path.home()) + + # Replace home directory with ~ if path starts with it + if path_str.startswith(home_str): + # Handle both /home/user and /home/user/ cases + relative = path_str[len(home_str) :] + if relative.startswith(os.sep): + relative = relative[1:] + return f"~/{relative}" if relative else "~" + + return path_str + + T = t.TypeVar("T", bound=dict[str, t.Any]) diff --git a/tests/test_utils.py b/tests/test_utils.py index f1875b98..b9a20dc3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,9 +2,10 @@ from __future__ import annotations +import pathlib import typing as t -from vcspull.util import get_config_dir +from vcspull.util import contract_user_home, get_config_dir if t.TYPE_CHECKING: import pathlib @@ -38,3 +39,42 @@ def test_vcspull_configdir_no_xdg(monkeypatch: pytest.MonkeyPatch) -> None: """Test retrieving config directory without XDG_CONFIG_HOME set.""" monkeypatch.delenv("XDG_CONFIG_HOME") assert get_config_dir() + + +def test_contract_user_home_contracts_home_path() -> None: + """Test contracting home directory to ~.""" + home = str(pathlib.Path.home()) + + # Test full path + assert contract_user_home(f"{home}/code/repo") == "~/code/repo" + + # Test home directory itself + assert contract_user_home(home) == "~" + + # Test with pathlib.Path + assert contract_user_home(pathlib.Path(home) / "code" / "repo") == "~/code/repo" + + +def test_contract_user_home_preserves_non_home_paths() -> None: + """Test that non-home paths are not contracted.""" + # Test absolute paths outside home + assert contract_user_home("/opt/project") == "/opt/project" + assert contract_user_home("/usr/local/bin") == "/usr/local/bin" + + # Test relative paths + assert contract_user_home("./relative/path") == "./relative/path" + assert contract_user_home("relative/path") == "relative/path" + + +def test_contract_user_home_handles_edge_cases() -> None: + """Test edge cases in path contraction.""" + home = str(pathlib.Path.home()) + + # Test trailing slashes + assert contract_user_home(f"{home}/code/") == "~/code/" + + # Test empty path + assert contract_user_home("") == "" + + # Test path with ~ already in it (should pass through) + assert contract_user_home("~/code/repo") == "~/code/repo"