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
15 changes: 15 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ $ uvx --from 'vcspull' --prerelease allow vcspull

_Upcoming changes will be written here._

### Bug fixes

#### Path privacy improvements (#488)

Path privacy added to:

- `vcspull fmt`: summary banners, success logs, and `--all` listings cache the
`PrivatePath` display value so config paths always render as `~/.vcspull.yaml`.
- `vcspull add`: invalid-config errors reuse the redacted path, keeping failure
logs consistent with other config operations.
- `vcspull discover`: warnings for repos without remotes and "no repos found"
notices collapse their target directories via `PrivatePath`.
- `vcspull sync`: human "Synced …" lines now mirror the structured JSON payloads
by showing tilde-collapsed repository paths.

## vcspull v1.47.0 (2025-11-15)

### Bug Fixes
Expand Down
2 changes: 1 addition & 1 deletion src/vcspull/cli/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ def add_repo(
except TypeError:
log.exception(
"Config file %s is not a valid YAML dictionary.",
config_file_path,
display_config_path,
)
return
except Exception:
Expand Down
6 changes: 3 additions & 3 deletions src/vcspull/cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ def discover_repos(
log.warning(
"Could not determine remote URL for git repository "
"at %s. Skipping.",
repo_path,
PrivatePath(repo_path),
)
continue

Expand All @@ -420,7 +420,7 @@ def discover_repos(
log.warning(
"Could not determine remote URL for git repository "
"at %s. Skipping.",
item,
PrivatePath(item),
)
continue

Expand All @@ -433,7 +433,7 @@ def discover_repos(
Fore.YELLOW,
Style.RESET_ALL,
Fore.BLUE,
scan_dir,
PrivatePath(scan_dir),
Style.RESET_ALL,
)
return
Expand Down
14 changes: 9 additions & 5 deletions src/vcspull/cli/fmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,17 @@ def format_single_config(
bool
True if formatting was successful, False otherwise
"""
# Precompute redacted path for CLI output.
display_config_path = str(PrivatePath(config_file_path))

# Check if file exists
if not config_file_path.exists():
log.error(
"%s✗%s Config file %s%s%s not found.",
Fore.RED,
Style.RESET_ALL,
Fore.BLUE,
config_file_path,
display_config_path,
Style.RESET_ALL,
)
return False
Expand Down Expand Up @@ -258,7 +261,7 @@ def format_single_config(
Fore.GREEN,
Style.RESET_ALL,
Fore.BLUE,
config_file_path,
display_config_path,
Style.RESET_ALL,
)
return True
Expand All @@ -273,7 +276,7 @@ def format_single_config(
Style.RESET_ALL,
"issue" if change_count == 1 else "issues",
Fore.BLUE,
config_file_path,
display_config_path,
Style.RESET_ALL,
)

Expand Down Expand Up @@ -362,7 +365,7 @@ def format_single_config(
Fore.GREEN,
Style.RESET_ALL,
Fore.BLUE,
config_file_path,
display_config_path,
Style.RESET_ALL,
)
except Exception:
Expand Down Expand Up @@ -438,12 +441,13 @@ def format_config_file(
)

for config_file in config_files:
display_config_file = str(PrivatePath(config_file))
log.info(
" %s•%s %s%s%s",
Fore.BLUE,
Style.RESET_ALL,
Fore.CYAN,
config_file,
display_config_file,
Style.RESET_ALL,
)

Expand Down
5 changes: 3 additions & 2 deletions src/vcspull/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -718,13 +718,14 @@ def silent_progress(output: str, timestamp: datetime) -> None:
repo_name = repo.get("name", "unknown")
repo_path = repo.get("path", "unknown")
workspace_label = repo.get("workspace_root", "")
display_repo_path = str(PrivatePath(repo_path))

summary["total"] += 1

event: dict[str, t.Any] = {
"reason": "sync",
"name": repo_name,
"path": str(PrivatePath(repo_path)),
"path": display_repo_path,
"workspace_root": str(workspace_label),
}

Expand Down Expand Up @@ -783,7 +784,7 @@ def silent_progress(output: str, timestamp: datetime) -> None:
formatter.emit(event)
formatter.emit_text(
f"{colors.success('✓')} Synced {colors.info(repo_name)} "
f"{colors.muted('→')} {repo_path}",
f"{colors.muted('→')} {display_repo_path}",
)

formatter.emit(
Expand Down
26 changes: 26 additions & 0 deletions tests/cli/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,32 @@ def test_add_repo_creates_new_file(
assert "newrepo" in config["~/"]


def test_add_repo_invalid_config_logs_private_path(
user_path: pathlib.Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Errors for invalid configs should report redacted paths."""
config_file = user_path / ".vcspull.yaml"
config_file.write_text("- repo: url\n", encoding="utf-8")

repo_dir = user_path / "projects" / "demo"
repo_dir.mkdir(parents=True, exist_ok=True)

caplog.set_level(logging.ERROR)

add_repo(
name="demo",
url="git+https://github.com/example/demo.git",
config_file_path_str=str(config_file),
path=str(repo_dir),
workspace_root_path=None,
dry_run=True,
)

expected_path = str(PrivatePath(config_file))
assert expected_path in caplog.text


class AddDuplicateMergeFixture(t.NamedTuple):
"""Fixture describing duplicate merge toggles for add_repo."""

Expand Down
36 changes: 36 additions & 0 deletions tests/cli/test_discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import logging
import pathlib
import re
import subprocess
Expand Down Expand Up @@ -809,6 +810,41 @@ def _fake_save(path: pathlib.Path, data: dict[str, t.Any]) -> None:
assert "Successfully updated" in caplog.text


def test_discover_logs_redact_scan_and_repo_paths(
user_path: pathlib.Path,
caplog: pytest.LogCaptureFixture,
monkeypatch: MonkeyPatch,
) -> None:
"""Warnings and info logs should display PrivatePath-collapsed values."""
scan_dir = user_path / "projects"
repo_dir = scan_dir / "demo"
(repo_dir / ".git").mkdir(parents=True, exist_ok=True)

caplog.set_level(logging.INFO, logger="vcspull.cli.discover")

monkeypatch.setattr(
"vcspull.cli.discover.get_git_origin_url",
lambda _path: None,
)

config_file = user_path / ".vcspull.yaml"
config_file.write_text("{}\n", encoding="utf-8")

discover_repos(
scan_dir_str=str(scan_dir),
config_file_path_str=str(config_file),
recursive=False,
workspace_root_override=None,
yes=True,
dry_run=True,
)

repo_display = str(PrivatePath(repo_dir))
scan_display = str(PrivatePath(scan_dir))
assert repo_display in caplog.text
assert scan_display in caplog.text


def test_discover_user_config_prefers_absolute_workspace_label(
tmp_path: pathlib.Path,
monkeypatch: MonkeyPatch,
Expand Down
41 changes: 35 additions & 6 deletions tests/cli/test_fmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import pytest
import yaml

from vcspull._internal.private_path import PrivatePath
from vcspull.cli import cli
from vcspull.cli.fmt import format_config, format_config_file, normalize_repo_config
from vcspull.config import (
Expand Down Expand Up @@ -303,6 +304,10 @@ def test_format_config_file_missing_config(
"""Formatting without available config should emit an error."""
monkeypatch.chdir(tmp_path)

home_config = pathlib.Path("~/.vcspull.yaml").expanduser()
if home_config.exists():
home_config.unlink()

with caplog.at_level(logging.ERROR):
format_config_file(None, write=False, format_all=False)

Expand Down Expand Up @@ -337,16 +342,40 @@ def test_format_config_file_reports_changes(
assert "Run with --write to apply" in text


def test_format_config_file_uses_private_path_in_logs(
user_path: pathlib.Path,
caplog: LogCaptureFixture,
) -> None:
"""CLI logs should redact the home directory via PrivatePath."""
config_file = user_path / ".vcspull.yaml"
config_file.write_text(
"""~/projects/:
zebra: url1
alpha: url2
""",
encoding="utf-8",
)

with caplog.at_level(logging.INFO):
format_config_file(str(config_file), write=True, format_all=False)

expected_path = str(PrivatePath(config_file))
text = caplog.text
assert f"in {expected_path}" in text
assert f"Successfully formatted {expected_path}" in text


def test_format_all_configs(
tmp_path: pathlib.Path,
caplog: LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
user_path: pathlib.Path,
) -> None:
"""format_config_file with --all should process discovered configs."""
config_dir = tmp_path / ".config" / "vcspull"
config_dir.mkdir(parents=True)
config_dir = user_path / ".config" / "vcspull"
config_dir.mkdir(parents=True, exist_ok=True)

home_config = tmp_path / ".vcspull.yaml"
home_config = user_path / ".vcspull.yaml"
home_config.write_text(
yaml.dump({"~/projects/": {"repo1": {"repo": "url1"}}}),
encoding="utf-8",
Expand Down Expand Up @@ -396,9 +425,9 @@ def fake_find_home_config_files(

text = caplog.text
assert "Found 3 configuration files to format" in text
assert str(home_config) in text
assert str(work_config) in text
assert str(local_config) in text
assert str(PrivatePath(home_config)) in text
assert str(PrivatePath(work_config)) in text
assert str(PrivatePath(local_config)) in text
assert "already formatted correctly" in text
assert "Repositories in ~/work/ will be sorted alphabetically" in text
assert "All 3 configuration files processed successfully" in text
Expand Down
73 changes: 73 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import yaml

from vcspull.__about__ import __version__
from vcspull._internal.private_path import PrivatePath
from vcspull.cli import cli
from vcspull.cli._output import PlanAction, PlanEntry, PlanResult, PlanSummary
from vcspull.cli.sync import EXIT_ON_ERROR_MSG, NO_REPOS_FOR_TERM_MSG
Expand Down Expand Up @@ -974,3 +975,75 @@ def test_sync_dry_run_plan_progress(
output = f"{captured.out}{captured.err}"
assert "Progress:" in output
assert "Plan:" in output


def test_sync_human_output_redacts_repo_paths(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
user_path: pathlib.Path,
) -> None:
"""Synced log lines should collapse repo paths via PrivatePath."""
repo_path = user_path / "repos" / "private"
repo_path.mkdir(parents=True, exist_ok=True)

repo_config = {
"name": "private",
"url": "git+https://example.com/private.git",
"path": str(repo_path),
"workspace_root": "~/repos/",
}

monkeypatch.setattr(
sync_module,
"load_configs",
lambda _paths: [repo_config],
)
monkeypatch.setattr(
sync_module,
"find_config_files",
lambda include_home=True: [],
)

def _fake_filter_repos(
_configs: list[dict[str, t.Any]],
*,
path: str | None = None,
vcs_url: str | None = None,
name: str | None = None,
) -> list[dict[str, t.Any]]:
if name and name != repo_config["name"]:
return []
if path and path != repo_config["path"]:
return []
if vcs_url and vcs_url != repo_config["url"]:
return []
return [repo_config]

monkeypatch.setattr(sync_module, "filter_repos", _fake_filter_repos)
monkeypatch.setattr(
sync_module,
"update_repo",
lambda _repo, progress_callback=None: None,
)

sync_module.sync(
repo_patterns=[repo_config["name"]],
config=None,
workspace_root=None,
dry_run=False,
output_json=False,
output_ndjson=False,
color="never",
exit_on_error=False,
show_unchanged=False,
summary_only=False,
long_view=False,
relative_paths=False,
fetch=False,
offline=False,
verbosity=0,
)

captured = capsys.readouterr()
expected_path = str(PrivatePath(repo_path))
assert expected_path in captured.out
Loading