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
11 changes: 10 additions & 1 deletion CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force

<!-- Maintainers, insert changes / features for the next release here -->

_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)

Expand Down
30 changes: 15 additions & 15 deletions docs/cli/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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/"
}
]
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions docs/cli/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
```
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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}
```

Expand Down
10 changes: 5 additions & 5 deletions docs/cli/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
},
Expand Down Expand Up @@ -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}
```

Expand Down
9 changes: 5 additions & 4 deletions src/vcspull/cli/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}",
)


Expand Down Expand Up @@ -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)}",
)
5 changes: 4 additions & 1 deletion src/vcspull/cli/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
4 changes: 4 additions & 0 deletions src/vcspull/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/vcspull/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])


Expand Down
42 changes: 41 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"