Skip to content

Commit 8e4b7e9

Browse files
authored
Streamline tildes for home directores (#473)
why: Introspection commands exposed full home paths (e.g., /home/username/...) in terminal output, revealing usernames and creating unnecessary verbosity. Users sharing screenshots or demos need more privacy-friendly output. what: - Add contract_user_home() utility to convert /home/user/path → ~/path - Apply path contraction to human-readable output in: - vcspull list (flat and tree views) - vcspull status (basic and detailed views) - vcspull sync --dry-run (plan output) - Preserve full absolute paths in JSON/NDJSON for automation accuracy - Update documentation examples to use ~/ instead of /home/d/ - Add 3 comprehensive test functions covering edge cases Before: • tiktoken → /home/username/study/ai/tiktoken ✗ flask: missing Path: /home/username/code/flask After: • tiktoken → ~/study/ai/tiktoken ✗ flask: missing Path: ~/code/flask JSON output (unchanged for automation): {"path": "/home/username/study/ai/tiktoken", ...} Benefits: - Better privacy: No username exposure in terminal - Improved UX: Shorter, cleaner paths - Safer sharing: Screenshots and demos are more portable - Familiar: Matches shell conventions Stats: 9 files changed, 125 insertions(+), 34 deletions(-) Tests: 195/195 passing (3 new test functions added)
2 parents 53974f1 + ece928a commit 8e4b7e9

9 files changed

Lines changed: 125 additions & 34 deletions

File tree

CHANGES

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,16 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force
3131

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

34-
_Notes on upcoming releases will be added here_
34+
### Improvements
35+
36+
#### Improved home directories paths (#472)
37+
38+
- **Human-readable output**: Contract `$HOME` to `~/` in introspection commands
39+
- `vcspull list`, `vcspull status`, and `vcspull sync --dry-run` now display
40+
paths like `~/code/flask` instead of `/home/username/code/flask`
41+
- Reduces verbosity and avoids exposing usernames in terminal output
42+
- JSON/NDJSON output preserves full absolute paths for accuracy in automation
43+
- **Documentation**: Updated all examples to use `~/` for consistency
3544

3645
## vcspull v1.39.0 (2025-10-19)
3746

docs/cli/list.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ List all configured repositories:
2323

2424
```console
2525
$ vcspull list
26-
• tiktoken → /home/d/study/ai/tiktoken
27-
• GeographicLib → /home/d/study/c++/GeographicLib
28-
• flask → /home/d/code/flask
26+
• tiktoken → ~/study/ai/tiktoken
27+
• GeographicLib → ~/study/c++/GeographicLib
28+
• flask → ~/code/flask
2929
```
3030

3131
## Filtering repositories
@@ -34,8 +34,8 @@ Filter repositories using fnmatch-style patterns:
3434

3535
```console
3636
$ vcspull list 'flask*'
37-
• flask → /home/d/code/flask
38-
• flask-sqlalchemy → /home/d/code/flask-sqlalchemy
37+
• flask → ~/code/flask
38+
• flask-sqlalchemy → ~/code/flask-sqlalchemy
3939
```
4040

4141
Multiple patterns are supported:
@@ -52,14 +52,14 @@ Group repositories by workspace root with `--tree`:
5252
$ vcspull list --tree
5353

5454
~/study/ai/
55-
• tiktoken → /home/d/study/ai/tiktoken
55+
• tiktoken → ~/study/ai/tiktoken
5656

5757
~/study/c++/
58-
• GeographicLib → /home/d/study/c++/GeographicLib
59-
• anax → /home/d/study/c++/anax
58+
• GeographicLib → ~/study/c++/GeographicLib
59+
• anax → ~/study/c++/anax
6060

6161
~/code/
62-
• flask → /home/d/code/flask
62+
• flask → ~/code/flask
6363
```
6464

6565
## JSON output
@@ -77,13 +77,13 @@ Output format:
7777
{
7878
"name": "tiktoken",
7979
"url": "git+https://github.com/openai/tiktoken.git",
80-
"path": "/home/d/study/ai/tiktoken",
80+
"path": "~/study/ai/tiktoken",
8181
"workspace_root": "~/study/ai/"
8282
},
8383
{
8484
"name": "flask",
8585
"url": "git+https://github.com/pallets/flask.git",
86-
"path": "/home/d/code/flask",
86+
"path": "~/code/flask",
8787
"workspace_root": "~/code/"
8888
}
8989
]
@@ -104,8 +104,8 @@ For streaming and line-oriented processing, use `--ndjson`:
104104

105105
```console
106106
$ vcspull list --ndjson
107-
{"name":"tiktoken","url":"git+https://github.com/openai/tiktoken.git","path":"/home/d/study/ai/tiktoken","workspace_root":"~/study/ai/"}
108-
{"name":"flask","url":"git+https://github.com/pallets/flask.git","path":"/home/d/code/flask","workspace_root":"~/code/"}
107+
{"name":"tiktoken","url":"git+https://github.com/openai/tiktoken.git","path":"~/study/ai/tiktoken","workspace_root":"~/study/ai/"}
108+
{"name":"flask","url":"git+https://github.com/pallets/flask.git","path":"~/code/flask","workspace_root":"~/code/"}
109109
```
110110

111111
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`:
134134

135135
```console
136136
$ vcspull list -w ~/code/
137-
• flask → /home/d/code/flask
138-
• requests → /home/d/code/requests
137+
• flask → ~/code/flask
138+
• requests → ~/code/requests
139139
```
140140

141141
Globbing is supported, so you can target multiple related workspaces:

docs/cli/status.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ Filter repositories using fnmatch-style patterns:
4242

4343
```console
4444
$ vcspull status 'django*'
45-
• django → /home/d/code/django (exists, clean)
46-
• django-extensions → /home/d/code/django-extensions (missing)
45+
• django → ~/code/django (exists, clean)
46+
• django-extensions → ~/code/django-extensions (missing)
4747
```
4848

4949
Multiple patterns are supported:
@@ -59,7 +59,7 @@ Show additional information with `--detailed` or `-d`:
5959
```console
6060
$ vcspull status --detailed
6161
✓ flask: up to date
62-
Path: /home/d/code/flask
62+
Path: ~/code/flask
6363
Branch: main
6464
Ahead/Behind: 0/0
6565
```
@@ -84,7 +84,7 @@ Output format:
8484
{
8585
"reason": "status",
8686
"name": "tiktoken",
87-
"path": "/home/d/study/ai/tiktoken",
87+
"path": "~/study/ai/tiktoken",
8888
"workspace_root": "~/study/ai/",
8989
"exists": false,
9090
"is_git": false,
@@ -96,7 +96,7 @@ Output format:
9696
{
9797
"reason": "status",
9898
"name": "flask",
99-
"path": "/home/d/code/flask",
99+
"path": "~/code/flask",
100100
"workspace_root": "~/code/",
101101
"exists": true,
102102
"is_git": true,
@@ -140,8 +140,8 @@ For streaming output, use `--ndjson`:
140140

141141
```console
142142
$ vcspull status --ndjson
143-
{"reason":"status","name":"tiktoken","path":"/home/d/study/ai/tiktoken","workspace_root":"~/study/ai/","exists":false,"is_git":false,"clean":null}
144-
{"reason":"status","name":"flask","path":"/home/d/code/flask","workspace_root":"~/code/","exists":true,"is_git":true,"clean":true}
143+
{"reason":"status","name":"tiktoken","path":"~/study/ai/tiktoken","workspace_root":"~/study/ai/","exists":false,"is_git":false,"clean":null}
144+
{"reason":"status","name":"flask","path":"~/code/flask","workspace_root":"~/code/","exists":true,"is_git":true,"clean":true}
145145
{"reason":"summary","total":2,"exists":1,"missing":1,"clean":1,"dirty":0}
146146
```
147147

docs/cli/sync.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ Preview what would be synchronized without making changes:
2525

2626
```console
2727
$ vcspull sync --dry-run '*'
28-
Would sync flask at /home/d/code/flask
29-
Would sync django at /home/d/code/django
30-
Would sync requests at /home/d/code/requests
28+
Would sync flask at ~/code/flask
29+
Would sync django at ~/code/django
30+
Would sync requests at ~/code/requests
3131
```
3232

3333
Use `--dry-run` or `-n` to:
@@ -46,7 +46,7 @@ $ vcspull sync --dry-run --json '*'
4646
{
4747
"reason": "sync",
4848
"name": "flask",
49-
"path": "/home/d/code/flask",
49+
"path": "~/code/flask",
5050
"workspace_root": "~/code/",
5151
"status": "preview"
5252
},
@@ -75,7 +75,7 @@ Stream sync events line-by-line with `--ndjson`:
7575

7676
```console
7777
$ vcspull sync --dry-run --ndjson '*'
78-
{"reason":"sync","name":"flask","path":"/home/d/code/flask","workspace_root":"~/code/","status":"preview"}
78+
{"reason":"sync","name":"flask","path":"~/code/flask","workspace_root":"~/code/","status":"preview"}
7979
{"reason":"summary","total":3,"synced":0,"previewed":3,"failed":0}
8080
```
8181

src/vcspull/cli/list.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import typing as t
99

1010
from vcspull.config import filter_repos, find_config_files, load_configs
11+
from vcspull.util import contract_user_home
1112

1213
from ._colors import Colors, get_color_mode
1314
from ._output import OutputFormatter, get_output_mode
@@ -170,10 +171,10 @@ def _output_flat(
170171
}
171172
)
172173

173-
# Human output
174+
# Human output (contract home directory for privacy/brevity)
174175
formatter.emit_text(
175176
f"{colors.muted('•')} {colors.info(repo_name)} "
176-
f"{colors.muted('→')} {repo_path}",
177+
f"{colors.muted('→')} {contract_user_home(repo_path)}",
177178
)
178179

179180

@@ -221,8 +222,8 @@ def _output_tree(
221222
}
222223
)
223224

224-
# Human output: indented repo
225+
# Human output: indented repo (contract home directory for privacy/brevity)
225226
formatter.emit_text(
226227
f" {colors.muted('•')} {colors.info(repo_name)} "
227-
f"{colors.muted('→')} {repo_path}",
228+
f"{colors.muted('→')} {contract_user_home(repo_path)}",
228229
)

src/vcspull/cli/status.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import typing as t
1010

1111
from vcspull.config import filter_repos, find_config_files, load_configs
12+
from vcspull.util import contract_user_home
1213

1314
from ._colors import Colors, get_color_mode
1415
from ._output import OutputFormatter, get_output_mode
@@ -344,7 +345,9 @@ def _format_status_line(
344345
formatter.emit_text(f"{symbol} {colors.info(name)}: {status_color}")
345346

346347
if detailed:
347-
formatter.emit_text(f" {colors.muted('Path:')} {status['path']}")
348+
formatter.emit_text(
349+
f" {colors.muted('Path:')} {contract_user_home(status['path'])}"
350+
)
348351
branch = status.get("branch")
349352
if branch:
350353
formatter.emit_text(f" {colors.muted('Branch:')} {branch}")

src/vcspull/cli/sync.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from vcspull import exc
2626
from vcspull.config import filter_repos, find_config_files, load_configs
2727
from vcspull.types import ConfigDict
28+
from vcspull.util import contract_user_home
2829

2930
from ._colors import Colors, get_color_mode
3031
from ._output import (
@@ -433,6 +434,9 @@ def _render_plan(
433434
display_path = str(rel_path)
434435
except ValueError:
435436
display_path = entry.path
437+
else:
438+
# Contract home directory for privacy/brevity in human output
439+
display_path = contract_user_home(display_path)
436440

437441
detail_text = _format_detail_text(
438442
entry,

src/vcspull/util.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,40 @@ def get_config_dir() -> pathlib.Path:
4242
return path
4343

4444

45+
def contract_user_home(path: str | pathlib.Path) -> str:
46+
"""Contract user home directory to ~ for display purposes.
47+
48+
Parameters
49+
----------
50+
path : str | pathlib.Path
51+
Path to contract
52+
53+
Returns
54+
-------
55+
str
56+
Path with $HOME contracted to ~
57+
58+
Examples
59+
--------
60+
>>> contract_user_home("/home/user/code/repo")
61+
'~/code/repo'
62+
>>> contract_user_home("/opt/project")
63+
'/opt/project'
64+
"""
65+
path_str = str(path)
66+
home_str = str(pathlib.Path.home())
67+
68+
# Replace home directory with ~ if path starts with it
69+
if path_str.startswith(home_str):
70+
# Handle both /home/user and /home/user/ cases
71+
relative = path_str[len(home_str) :]
72+
if relative.startswith(os.sep):
73+
relative = relative[1:]
74+
return f"~/{relative}" if relative else "~"
75+
76+
return path_str
77+
78+
4579
T = t.TypeVar("T", bound=dict[str, t.Any])
4680

4781

tests/test_utils.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from __future__ import annotations
44

5+
import pathlib
56
import typing as t
67

7-
from vcspull.util import get_config_dir
8+
from vcspull.util import contract_user_home, get_config_dir
89

910
if t.TYPE_CHECKING:
1011
import pathlib
@@ -38,3 +39,42 @@ def test_vcspull_configdir_no_xdg(monkeypatch: pytest.MonkeyPatch) -> None:
3839
"""Test retrieving config directory without XDG_CONFIG_HOME set."""
3940
monkeypatch.delenv("XDG_CONFIG_HOME")
4041
assert get_config_dir()
42+
43+
44+
def test_contract_user_home_contracts_home_path() -> None:
45+
"""Test contracting home directory to ~."""
46+
home = str(pathlib.Path.home())
47+
48+
# Test full path
49+
assert contract_user_home(f"{home}/code/repo") == "~/code/repo"
50+
51+
# Test home directory itself
52+
assert contract_user_home(home) == "~"
53+
54+
# Test with pathlib.Path
55+
assert contract_user_home(pathlib.Path(home) / "code" / "repo") == "~/code/repo"
56+
57+
58+
def test_contract_user_home_preserves_non_home_paths() -> None:
59+
"""Test that non-home paths are not contracted."""
60+
# Test absolute paths outside home
61+
assert contract_user_home("/opt/project") == "/opt/project"
62+
assert contract_user_home("/usr/local/bin") == "/usr/local/bin"
63+
64+
# Test relative paths
65+
assert contract_user_home("./relative/path") == "./relative/path"
66+
assert contract_user_home("relative/path") == "relative/path"
67+
68+
69+
def test_contract_user_home_handles_edge_cases() -> None:
70+
"""Test edge cases in path contraction."""
71+
home = str(pathlib.Path.home())
72+
73+
# Test trailing slashes
74+
assert contract_user_home(f"{home}/code/") == "~/code/"
75+
76+
# Test empty path
77+
assert contract_user_home("") == ""
78+
79+
# Test path with ~ already in it (should pass through)
80+
assert contract_user_home("~/code/repo") == "~/code/repo"

0 commit comments

Comments
 (0)