diff --git a/CHANGES b/CHANGES index e78386ee..50c136b7 100644 --- a/CHANGES +++ b/CHANGES @@ -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 diff --git a/src/vcspull/cli/add.py b/src/vcspull/cli/add.py index 4a938c2c..ef917384 100644 --- a/src/vcspull/cli/add.py +++ b/src/vcspull/cli/add.py @@ -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: diff --git a/src/vcspull/cli/discover.py b/src/vcspull/cli/discover.py index fdd91692..bf951919 100644 --- a/src/vcspull/cli/discover.py +++ b/src/vcspull/cli/discover.py @@ -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 @@ -420,7 +420,7 @@ def discover_repos( log.warning( "Could not determine remote URL for git repository " "at %s. Skipping.", - item, + PrivatePath(item), ) continue @@ -433,7 +433,7 @@ def discover_repos( Fore.YELLOW, Style.RESET_ALL, Fore.BLUE, - scan_dir, + PrivatePath(scan_dir), Style.RESET_ALL, ) return diff --git a/src/vcspull/cli/fmt.py b/src/vcspull/cli/fmt.py index 4967ee78..a56b057e 100644 --- a/src/vcspull/cli/fmt.py +++ b/src/vcspull/cli/fmt.py @@ -159,6 +159,9 @@ 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( @@ -166,7 +169,7 @@ def format_single_config( Fore.RED, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, ) return False @@ -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 @@ -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, ) @@ -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: @@ -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, ) diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py index 1b512ebf..3a140f7f 100644 --- a/src/vcspull/cli/sync.py +++ b/src/vcspull/cli/sync.py @@ -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), } @@ -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( diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index c09c9271..20225612 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -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.""" diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index b2932794..238b49bb 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import pathlib import re import subprocess @@ -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, diff --git a/tests/cli/test_fmt.py b/tests/cli/test_fmt.py index 8fa4b752..dedb53c0 100644 --- a/tests/cli/test_fmt.py +++ b/tests/cli/test_fmt.py @@ -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 ( @@ -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) @@ -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", @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 0f9e4e37..48a9056b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 @@ -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