Skip to content
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

#### `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.

#### `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)
Expand Down
103 changes: 91 additions & 12 deletions src/vcspull/cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -186,7 +245,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:
Expand All @@ -197,6 +256,13 @@ def discover_repos(
else:
config_file_path = home_configs[0]

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():
Expand All @@ -209,7 +275,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:
Expand All @@ -225,7 +291,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:
Expand All @@ -236,7 +302,7 @@ def discover_repos(
Fore.CYAN,
Style.RESET_ALL,
Fore.BLUE,
config_file_path,
display_config_path,
Style.RESET_ALL,
)

Expand Down Expand Up @@ -286,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:
(
Expand All @@ -299,6 +365,7 @@ def discover_repos(
raw_config,
cwd=cwd,
home=home,
preserve_cwd_label=preserve_cwd_label,
)
else:
(
Expand All @@ -310,6 +377,7 @@ def discover_repos(
raw_config,
cwd=cwd,
home=home,
preserve_cwd_label=preserve_cwd_label,
)

for message in merge_conflicts:
Expand Down Expand Up @@ -376,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, {})

Expand Down Expand Up @@ -414,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, {})
Expand All @@ -432,7 +506,7 @@ def discover_repos(
name,
Style.RESET_ALL,
Fore.BLUE,
config_file_path,
display_config_path,
Style.RESET_ALL,
)

Expand All @@ -458,7 +532,7 @@ def discover_repos(
Fore.GREEN,
Style.RESET_ALL,
Fore.BLUE,
config_file_path,
display_config_path,
Style.RESET_ALL,
)
except Exception:
Expand Down Expand Up @@ -499,7 +573,7 @@ def discover_repos(
Fore.YELLOW,
Style.RESET_ALL,
Fore.BLUE,
config_file_path,
display_config_path,
Style.RESET_ALL,
)
return
Expand All @@ -515,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:
Expand Down Expand Up @@ -554,7 +633,7 @@ def discover_repos(
Fore.GREEN,
Style.RESET_ALL,
Fore.BLUE,
config_file_path,
display_config_path,
Style.RESET_ALL,
)
except Exception:
Expand Down
2 changes: 2 additions & 0 deletions src/vcspull/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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

Expand Down
Loading
Loading