From db07c08156c35461354eb42c8d3b7a489301ea97 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Nov 2025 04:20:24 -0600 Subject: [PATCH 1/9] cli/discover(fix[path-redaction]): reuse PrivatePath for logs --- src/vcspull/cli/discover.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/vcspull/cli/discover.py b/src/vcspull/cli/discover.py index f9de8555..550b0b94 100644 --- a/src/vcspull/cli/discover.py +++ b/src/vcspull/cli/discover.py @@ -186,7 +186,7 @@ def discover_repos( Fore.CYAN, Style.RESET_ALL, Fore.BLUE, - config_file_path, + PrivatePath(config_file_path), Style.RESET_ALL, ) elif len(home_configs) > 1: @@ -197,6 +197,8 @@ def discover_repos( else: config_file_path = home_configs[0] + display_config_path = str(PrivatePath(config_file_path)) + raw_config: dict[str, t.Any] duplicate_root_occurrences: dict[str, list[t.Any]] if config_file_path.exists() and config_file_path.is_file(): @@ -209,7 +211,7 @@ def discover_repos( except TypeError: log.exception( "Config file %s is not a valid YAML dictionary.", - config_file_path, + display_config_path, ) return except Exception: @@ -225,7 +227,7 @@ def discover_repos( elif not isinstance(raw_config, dict): log.error( "Config file %s is not a valid YAML dictionary.", - config_file_path, + display_config_path, ) return else: @@ -236,7 +238,7 @@ def discover_repos( Fore.CYAN, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, ) @@ -432,7 +434,7 @@ def discover_repos( name, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, ) @@ -458,7 +460,7 @@ def discover_repos( Fore.GREEN, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, ) except Exception: @@ -499,7 +501,7 @@ def discover_repos( Fore.YELLOW, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, ) return @@ -554,7 +556,7 @@ def discover_repos( Fore.GREEN, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, ) except Exception: From 540efce1140d6ee5982fed7e836281ace5552bcb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Nov 2025 04:20:26 -0600 Subject: [PATCH 2/9] tests/discover(fix[snapshots]): normalize PrivatePath output --- tests/cli/test_discover.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index f3cfcb09..e52d7fe6 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -8,6 +8,7 @@ import pytest +from vcspull._internal.private_path import PrivatePath from vcspull.cli.discover import discover_repos if t.TYPE_CHECKING: @@ -362,6 +363,10 @@ def test_discover_repos( if preexisting_yaml is not None or not merge_duplicates: normalized_log = caplog.text.replace(str(target_config_file), "") + normalized_log = normalized_log.replace( + str(PrivatePath(target_config_file)), + "", + ) normalized_log = re.sub( r"discover\.py:\d+", "discover.py:", From 33cc0d61d96ee5663a75eabc82d0b4055030a5b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Nov 2025 04:23:36 -0600 Subject: [PATCH 3/9] docs(CHANGES): note discover PrivatePath logging fix --- CHANGES | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGES b/CHANGES index 756114dd..375ddfc4 100644 --- a/CHANGES +++ b/CHANGES @@ -33,6 +33,14 @@ $ uvx --from 'vcspull' --prerelease allow vcspull _Upcoming changes will be written here._ +### Bug Fixes + +#### `vcspull discover`: Success logs redact config paths (#487) + +Writes triggered by `vcspull discover` now pass the config file through +`PrivatePath`, so confirmations like "✓ Successfully updated ~/.vcspull.yaml" +and dry-run notices no longer leak the absolute home directory. + ### Development #### PrivatePath centralizes home-directory redaction (#485) From 120a745bb019a12081781b5c5f2550006554ecae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Nov 2025 04:35:56 -0600 Subject: [PATCH 4/9] tests/discover(add[xfail]): reproduce user-level workspace bug --- tests/cli/test_discover.py | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index e52d7fe6..a51d1d82 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -698,6 +698,64 @@ def _fake_save(path: pathlib.Path, data: dict[str, t.Any]) -> None: assert "Successfully updated" in caplog.text +@pytest.mark.xfail(reason="discover uses ./ for user-level configs (#487)", strict=True) +def test_discover_user_config_prefers_absolute_workspace_label( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, + caplog: t.Any, +) -> None: + import logging + import yaml + + caplog.set_level(logging.INFO) + + home = tmp_path + monkeypatch.setenv("HOME", str(home)) + + scan_dir = home / "study" / "golang" + scan_dir.mkdir(parents=True) + monkeypatch.chdir(scan_dir) + + init_git_repo(scan_dir / "moby", "git+https://github.com/moby/moby.git") + init_git_repo( + scan_dir / "typescript-go", + "git+https://github.com/microsoft/typescript-go.git", + ) + + config_file = home / ".vcspull.yaml" + config_file.write_text( + yaml.dump( + { + "~/study/ai-agents/": { + "aider": { + "repo": "git@github.com:Aider-AI/aider.git", + }, + }, + }, + ), + encoding="utf-8", + ) + + discover_repos( + scan_dir_str=str(scan_dir), + config_file_path_str=None, + recursive=False, + workspace_root_override=None, + yes=True, + dry_run=False, + ) + + assert "Successfully updated ~/.vcspull.yaml" in caplog.text + + with config_file.open(encoding="utf-8") as fh: + config_data = yaml.safe_load(fh) + + assert config_data is not None + assert "~/study/golang/" in config_data + assert "./" not in config_data + assert set(config_data["~/study/golang/"]) == {"moby", "typescript-go"} + + @pytest.mark.parametrize( list(DiscoverInvalidWorkspaceFixture._fields), DISCOVER_INVALID_WORKSPACE_FIXTURES, From 79a8d3bcd26f8fe28ac51eceb749c88e79a7d673 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Nov 2025 04:55:55 -0600 Subject: [PATCH 5/9] config(normalize): allow callers to skip cwd label contraction --- src/vcspull/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vcspull/config.py b/src/vcspull/config.py index 25ab97f6..a33fd463 100644 --- a/src/vcspull/config.py +++ b/src/vcspull/config.py @@ -617,6 +617,7 @@ def normalize_workspace_roots( *, cwd: pathlib.Path | None = None, home: pathlib.Path | None = None, + preserve_cwd_label: bool = True, ) -> tuple[dict[str, t.Any], dict[pathlib.Path, str], list[str], int]: """Normalize workspace root labels and merge duplicate sections.""" cwd = cwd or pathlib.Path.cwd() @@ -636,6 +637,7 @@ def normalize_workspace_roots( canonical_path, cwd=cwd, home=home, + preserve_cwd_label=preserve_cwd_label, ) path_to_label[canonical_path] = normalized_label From 25c905ff52944b03cea51e353d0ba3708f901083 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Nov 2025 04:55:59 -0600 Subject: [PATCH 6/9] cli/discover(feat[config-scope]): honor user vs project workspace labels --- src/vcspull/cli/discover.py | 85 +++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/src/vcspull/cli/discover.py b/src/vcspull/cli/discover.py index 550b0b94..fdd91692 100644 --- a/src/vcspull/cli/discover.py +++ b/src/vcspull/cli/discover.py @@ -28,6 +28,65 @@ log = logging.getLogger(__name__) +ConfigScope = t.Literal["system", "user", "project", "external"] + + +def _classify_config_scope( + config_path: pathlib.Path, + *, + cwd: pathlib.Path, + home: pathlib.Path, +) -> ConfigScope: + """Determine whether a config lives in user, system, project, or external scope.""" + resolved = config_path.expanduser().resolve() + home = home.expanduser().resolve() + cwd = cwd.expanduser().resolve() + + default_user_configs = { + (home / ".vcspull.yaml").resolve(), + (home / ".vcspull.json").resolve(), + } + if resolved in default_user_configs: + return "user" + + xdg_config_home = ( + pathlib.Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) + .expanduser() + .resolve() + ) + user_config_root = (xdg_config_home / "vcspull").resolve() + try: + resolved.relative_to(user_config_root) + except ValueError: + pass + else: + return "user" + + xdg_config_dirs_value = os.environ.get("XDG_CONFIG_DIRS") + if xdg_config_dirs_value: + config_dir_bases = [ + pathlib.Path(entry).expanduser().resolve() + for entry in xdg_config_dirs_value.split(os.pathsep) + if entry + ] + else: + config_dir_bases = [pathlib.Path("/etc/xdg").resolve()] + + for base in config_dir_bases: + candidate = (base / "vcspull").resolve() + try: + resolved.relative_to(candidate) + except ValueError: + continue + else: + return "system" + + try: + resolved.relative_to(cwd) + except ValueError: + return "external" + return "project" + def get_git_origin_url(repo_path: pathlib.Path) -> str | None: """Get the origin URL from a git repository. @@ -199,6 +258,11 @@ def discover_repos( display_config_path = str(PrivatePath(config_file_path)) + cwd = pathlib.Path.cwd() + home = pathlib.Path.home() + config_scope = _classify_config_scope(config_file_path, cwd=cwd, home=home) + allow_relative_workspace = config_scope == "project" + raw_config: dict[str, t.Any] duplicate_root_occurrences: dict[str, list[t.Any]] if config_file_path.exists() and config_file_path.is_file(): @@ -288,8 +352,8 @@ def discover_repos( "" if occurrence_count == 1 else "s", ) - cwd = pathlib.Path.cwd() - home = pathlib.Path.home() + explicit_relative_override = workspace_root_override in {".", "./"} + preserve_cwd_label = explicit_relative_override or allow_relative_workspace if merge_duplicates: ( @@ -301,6 +365,7 @@ def discover_repos( raw_config, cwd=cwd, home=home, + preserve_cwd_label=preserve_cwd_label, ) else: ( @@ -312,6 +377,7 @@ def discover_repos( raw_config, cwd=cwd, home=home, + preserve_cwd_label=preserve_cwd_label, ) for message in merge_conflicts: @@ -378,7 +444,12 @@ def discover_repos( for name, url, workspace_path in found_repos: workspace_label = workspace_map.get(workspace_path) if workspace_label is None: - workspace_label = workspace_root_label(workspace_path, cwd=cwd, home=home) + workspace_label = workspace_root_label( + workspace_path, + cwd=cwd, + home=home, + preserve_cwd_label=preserve_cwd_label, + ) workspace_map[workspace_path] = workspace_label raw_config.setdefault(workspace_label, {}) @@ -416,6 +487,7 @@ def discover_repos( workspace_path, cwd=cwd, home=home, + preserve_cwd_label=preserve_cwd_label, ) workspace_map[workspace_path] = workspace_label raw_config.setdefault(workspace_label, {}) @@ -517,7 +589,12 @@ def discover_repos( for repo_name, repo_url, workspace_path in repos_to_add: workspace_label = workspace_map.get(workspace_path) if workspace_label is None: - workspace_label = workspace_root_label(workspace_path, cwd=cwd, home=home) + workspace_label = workspace_root_label( + workspace_path, + cwd=cwd, + home=home, + preserve_cwd_label=preserve_cwd_label, + ) workspace_map[workspace_path] = workspace_label if workspace_label not in raw_config: From 522a3a99bad687a831f56d81b85d715a94e68de5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Nov 2025 04:56:04 -0600 Subject: [PATCH 7/9] tests/discover(add[scope]): cover user and project configs --- tests/cli/test_discover.py | 41 +++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index a51d1d82..ef89e6e1 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -698,7 +698,6 @@ def _fake_save(path: pathlib.Path, data: dict[str, t.Any]) -> None: assert "Successfully updated" in caplog.text -@pytest.mark.xfail(reason="discover uses ./ for user-level configs (#487)", strict=True) def test_discover_user_config_prefers_absolute_workspace_label( tmp_path: pathlib.Path, monkeypatch: MonkeyPatch, @@ -756,6 +755,46 @@ def test_discover_user_config_prefers_absolute_workspace_label( assert set(config_data["~/study/golang/"]) == {"moby", "typescript-go"} +def test_discover_project_config_retains_relative_workspace_label( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, +) -> None: + import yaml + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + + project_root = tmp_path / "project" + project_root.mkdir() + monkeypatch.chdir(project_root) + + init_git_repo(project_root / "repo-a", "git+https://github.com/example/repo-a.git") + init_git_repo(project_root / "repo-b", "git+https://github.com/example/repo-b.git") + + config_file = project_root / ".vcspull.yaml" + config_file.write_text( + yaml.dump({}), + encoding="utf-8", + ) + + discover_repos( + scan_dir_str=str(project_root), + config_file_path_str=str(config_file), + recursive=False, + workspace_root_override=None, + yes=True, + dry_run=False, + ) + + with config_file.open(encoding="utf-8") as fh: + config_data = yaml.safe_load(fh) + + assert config_data is not None + assert "./" in config_data + assert set(config_data["./"]) == {"repo-a", "repo-b"} + + @pytest.mark.parametrize( list(DiscoverInvalidWorkspaceFixture._fields), DISCOVER_INVALID_WORKSPACE_FIXTURES, From 7c6e167e4dfec9ff9c9498a872c2dfea171ee1e6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Nov 2025 04:56:09 -0600 Subject: [PATCH 8/9] docs(CHANGES): mention discover scope-aware labeling --- CHANGES | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES b/CHANGES index 375ddfc4..35c505ed 100644 --- a/CHANGES +++ b/CHANGES @@ -41,6 +41,13 @@ Writes triggered by `vcspull discover` now pass the config file through `PrivatePath`, so confirmations like "✓ Successfully updated ~/.vcspull.yaml" and dry-run notices no longer leak the absolute home directory. +#### `vcspull discover`: Fix another workspace dir case (#487) + +Discover now inspects the config scope (user/system/project) before writing, +so user-level configs like `~/.vcspull.yaml` prefer tilde-prefixed workspace +keys while project-level configs keep their relative `./` sections. Tests +cover both behaviors to guard against regressions. + ### Development #### PrivatePath centralizes home-directory redaction (#485) From 78fd3fec385d95eac2294b6849bba631c2161022 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Nov 2025 04:57:44 -0600 Subject: [PATCH 9/9] tests/discover(add[config-scope]): parametrize classify helper --- tests/cli/test_discover.py | 116 ++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index ef89e6e1..b2932794 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pathlib import re import subprocess import typing as t @@ -9,7 +10,7 @@ import pytest from vcspull._internal.private_path import PrivatePath -from vcspull.cli.discover import discover_repos +from vcspull.cli.discover import ConfigScope, _classify_config_scope, discover_repos if t.TYPE_CHECKING: import pathlib @@ -202,6 +203,55 @@ class DiscoverFixture(t.NamedTuple): ] +class ConfigScopeFixture(t.NamedTuple): + """Fixture describing config scope classification scenarios.""" + + test_id: str + config_template: str + cwd_template: str + env: dict[str, str] + expected_scope: ConfigScope + + +CONFIG_SCOPE_FIXTURES: list[ConfigScopeFixture] = [ + ConfigScopeFixture( + test_id="scope-user-home-default", + config_template="{home}/.vcspull.yaml", + cwd_template="{home}", + env={}, + expected_scope="user", + ), + ConfigScopeFixture( + test_id="scope-user-xdg-home", + config_template="{xdg_home}/vcspull/personal.yaml", + cwd_template="{home}", + env={"XDG_CONFIG_HOME": "{xdg_home}"}, + expected_scope="user", + ), + ConfigScopeFixture( + test_id="scope-system-xdg-dirs", + config_template="{xdg_system}/vcspull/system.yaml", + cwd_template="{home}", + env={"XDG_CONFIG_DIRS": "{xdg_system}"}, + expected_scope="system", + ), + ConfigScopeFixture( + test_id="scope-project-relative", + config_template="{project}/.vcspull.yaml", + cwd_template="{project}", + env={}, + expected_scope="project", + ), + ConfigScopeFixture( + test_id="scope-external-file", + config_template="{external}/configs/custom.yaml", + cwd_template="{project}", + env={}, + expected_scope="external", + ), +] + + class DiscoverLoadEdgeFixture(t.NamedTuple): """Fixture describing discover configuration loading edge cases.""" @@ -401,6 +451,67 @@ def test_discover_repos( ) +@pytest.mark.parametrize( + list(ConfigScopeFixture._fields), + CONFIG_SCOPE_FIXTURES, + ids=[fixture.test_id for fixture in CONFIG_SCOPE_FIXTURES], +) +def test_classify_config_scope( + test_id: str, + config_template: str, + cwd_template: str, + env: dict[str, str], + expected_scope: ConfigScope, + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, +) -> None: + """Ensure _classify_config_scope handles user/system/project/external paths.""" + base_home = tmp_path / "home" + base_home.mkdir() + project = tmp_path / "project" + project.mkdir() + xdg_home = tmp_path / "xdg-home" + (xdg_home / "vcspull").mkdir(parents=True) + xdg_system = tmp_path / "xdg-system" + (xdg_system / "vcspull").mkdir(parents=True) + external = tmp_path / "external" + (external / "configs").mkdir(parents=True) + + replacements = { + "home": base_home, + "project": project, + "xdg_home": xdg_home, + "xdg_system": xdg_system, + "external": external, + } + + def _expand(template: str) -> pathlib.Path: + expanded = template + for key, path in replacements.items(): + expanded = expanded.replace(f"{{{key}}}", str(path)) + return pathlib.Path(expanded) + + config_path = _expand(config_template) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.touch() + + cwd_path = _expand(cwd_template) + cwd_path.mkdir(parents=True, exist_ok=True) + + monkeypatch.chdir(cwd_path) + monkeypatch.setenv("HOME", str(base_home)) + for var in ["XDG_CONFIG_HOME", "XDG_CONFIG_DIRS"]: + monkeypatch.delenv(var, raising=False) + for key, value in env.items(): + expanded_value = value + for name, path in replacements.items(): + expanded_value = expanded_value.replace(f"{{{name}}}", str(path)) + monkeypatch.setenv(key, expanded_value) + + scope = _classify_config_scope(config_path, cwd=cwd_path, home=base_home) + assert scope == expected_scope + + @pytest.mark.parametrize( list(DiscoverLoadEdgeFixture._fields), DISCOVER_LOAD_EDGE_FIXTURES, @@ -703,7 +814,9 @@ def test_discover_user_config_prefers_absolute_workspace_label( monkeypatch: MonkeyPatch, caplog: t.Any, ) -> None: + """User-level configs default to tilde-prefixed workspace labels.""" import logging + import yaml caplog.set_level(logging.INFO) @@ -759,6 +872,7 @@ def test_discover_project_config_retains_relative_workspace_label( tmp_path: pathlib.Path, monkeypatch: MonkeyPatch, ) -> None: + """Project-level configs keep relative './' workspace sections.""" import yaml home = tmp_path / "home"