Skip to content

Configuration scopes: system, user, and project config resolution#564

Open
tony wants to merge 7 commits into
masterfrom
scope-module
Open

Configuration scopes: system, user, and project config resolution#564
tony wants to merge 7 commits into
masterfrom
scope-module

Conversation

@tony

@tony tony commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

  • Add four configuration scopes — system (/etc/vcspull/), user (XDG dir and ~/.vcspull.*), and project (.vcspull.* discovered by walking up from the working directory to $HOME). All scopes are unioned, so repositories in your home config stay available from any directory while a project can add its own on top.
  • Replace the fatal duplicate-path error with defined precedence: when two scopes declare the same destination, the nearest scope wins the whole entry, and a warning is emitted only when the winning and losing entries actually differ in URL or VCS.
  • Add a consent gate for project configs: a config that would clone outside its own directory prompts once ([y/N/always]), errors cleanly without a TTY instead of hanging, and is silent when self-contained. Containment is checked against resolved destinations, honoring per-entry path: overrides.
  • Add vcspull config ls (shows the scopes in effect, weakest-first, with override signals) and vcspull trust / --untrust / --show.
  • Add --no-project and --trust-project (plus VCSPULL_NO_PROJECT / VCSPULL_YES), accepted before or after the subcommand.
  • Fix discovery to accept .yml uniformly and to scan every configured path rather than only the first.

Changes by area

Resolution

  • src/vcspull/_internal/scopes.py: New pure resolver — emits an ordered ConfigSource sequence and answers containment/trust questions with no parsing and no prompting. Consumed by loading, config ls, trust, and discover's label logic.
  • src/vcspull/config.py: load_scoped_configs walks the resolved sources; the merge applies nearest-wins by destination key; the consent gate runs after destinations are resolved.

CLI

  • src/vcspull/cli/config.py: config ls and trust command handlers.
  • src/vcspull/cli/__init__.py: Subcommand wiring, shared scope/trust flag parsers, and a top-level handler that reports VCSPullException as one line to stderr instead of a traceback.
  • Read commands (sync, list, status, search, worktree): resolve the scope stack by default.
  • Write commands (add, discover, fmt, migrate): consent gate applied at the funnel each path traverses.

Docs

  • docs/configuration/scopes.md: New scopes-and-trust page.
  • docs/cli/config.md, docs/cli/trust.md: Reference pages with generated argument blocks.
  • Corrected the search-order prose in docs/cli/add.md / docs/cli/list.md and the --file precedence in fmt / migrate help.

Design decisions

  • Union, not override: A vcspull config is a manifest of repositories, not a settings file, so scopes union rather than replace — following mise's tool-resolution model. Nearest-wins applies per destination key, whole-entry (no field-level merge), so a project entry never inherits a stray rev: from a home entry it shadows.
  • Trust gates the project tier only: System, user, and explicit --file are auto-trusted; only discovered project configs, which may come from a cloned repository, are gated. --file is not gated because it replaces the stack rather than joining the project tier.
  • Containment reads destinations, not labels: A per-entry path: can redirect a clone outside a workspace root, so containment resolves the actual destination extract_repos will use.
  • Out of scope, tracked separately: relative workspace roots resolve against the process working directory rather than the config's own directory (Relative workspace roots resolve against the process cwd, not the config's directory #562), and expand_dir asserts against a symlinked cwd (expand_dir assertion fails on a relative workspace root under a symlinked cwd #561). Both are pre-existing and made more visible by discovered ancestor configs; neither is changed here.

Verification

Read commands resolve project configs (returns discovered scopes, not just the home config):

rg -n "load_scoped_configs" src/vcspull/cli

No command bypasses the consent gate on the write path:

rg -n "ensure_config_trusted" src/vcspull/cli

Test plan

  • uv run ruff format --check . and uv run ruff check . — clean
  • uv run mypy — clean under strict = true
  • uv run py.test — full suite green
  • just build-docs — no Sphinx warnings
  • tests/test_scopes.py — tier order, $HOME ceiling exclusion (including symlinked $HOME), union with nearest-wins, --file replaces the stack, VCSPULL_NO_PROJECT drops the project tier
  • tests/cli/test_config.pyconfig ls order and override signal, untrusted marker, no-TTY one-line error, trust round-trip, fmt -f gating, and that discover --yes does not grant trust
  • path: override cannot smuggle a destination outside a contained config
  • hostile scope-directory contents (comment-only file, directory named *.yaml, unreadable dir) do not crash discovery

tony added 7 commits July 21, 2026 17:36
why: Configuration came from one home dotfile and whatever
find_config_files happened to turn up, so a repository could not ship
a checkout manifest next to its code. Resolving system, user, and
project scopes into one ordered stack gives every directory a
predictable answer to "which configuration is in effect here?".

what:
- Add a resolver that orders system, user, and per-ancestor project
  configuration files weakest to strongest.
- Bound the upward project walk at $HOME, overridable through
  VCSPULL_CEILING_PATHS, and follow symlinks on both sides so a linked
  home still stops the walk.
- Let VCSPULL_NO_PROJECT drop the project scope entirely.
- Classify a caller-supplied path as system, user, project, or
  external, and have discover share that classification.
- Tolerate scope directories that are unreadable or hold a directory
  named like a configuration file.
why: Loading several configuration files kept the first entry for a
repository and aborted outright when two files named the same
destination with different VCS. With a resolved scope stack that is
the normal case, not an error: a project file exists precisely to
repoint a repository the user config also names.

what:
- Union entries across files by destination and let the strongest file
  replace a colliding entry whole, so a repointed repository does not
  inherit the weaker file's options.
- Warn only when a replaced entry actually disagrees on url or vcs;
  identical duplicates across scopes stay silent.
why: A .vcspull.yaml found by walking up from the working directory is
code from whoever wrote that repository. Loading it unconditionally
would let a cloned project choose where vcspull checks repositories
out, including paths like ~/.ssh.

what:
- Read every scope through one loader that asks each project config
  whether its checkout destinations stay inside its own directory.
- Prompt once per project directory when a destination escapes,
  remember an "always" answer, and hard-error instead of prompting
  when there is no terminal.
- Check destinations rather than workspace-root keys, so a per-entry
  path: override cannot smuggle a checkout out of a contained tree.
- Gate add, discover, fmt, and migrate on the same question before
  they rewrite a project file vcspull found on its own; naming a file
  with --file remains consent.
- Accept --trust-project and --no-project both before and after the
  subcommand, and honour VCSPULL_YES.
- Resolve fmt --all and migrate --all through the scope stack.
why: A VCSPullException is vcspull telling the user something they can
act on, such as an untrusted project config or an unreadable file.
Letting it reach the interpreter buried that sentence under a
traceback and exited with the wrong status.

what:
- Catch VCSPullException at the entry point, print it as a single
  stderr line, and exit 1.
- Keep the traceback available under --log-level debug.
why: With several files contributing to one stack, a user who sees an
unexpected repository — or a missing one — has no way to ask which
files were read, which entries a nearer file overrode, and which
project config is waiting on consent.

what:
- Add 'vcspull config ls', printing every resolved file weakest first
  with its scope, repository count, overridden count, and whether it
  is untrusted.
- Add 'vcspull trust' to record, remove, or print trusted project
  directories.
why: find_config_files returned after its first path whenever it was
handed a list, so only one directory was ever searched. It also
ignored the .yml suffix, leaving those files invisible to every
command that discovers configuration.

what:
- Search every path in a list instead of returning after the first.
- Accept .yml alongside .yaml and .json.
why: Scope resolution changes which files vcspull reads and adds a
consent step that can stop a load, so the reference needs to say where
configuration comes from, how a nearer file overrides a weaker one,
and what to do when a project config is refused.

what:
- Add a scopes page covering the resolution order, the bounded project
  walk, the containment rule, and the environment overrides.
- Add reference pages for the config and trust commands.
- Note the scope stack and its flags on the CLI index and on the
  commands that resolve it.
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.42484% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.99%. Comparing base (fea3764) to head (fd12a56).

Files with missing lines Patch % Lines
src/vcspull/cli/migrate.py 37.50% 4 Missing and 1 partial ⚠️
src/vcspull/cli/config.py 96.10% 3 Missing ⚠️
src/vcspull/cli/add.py 0.00% 1 Missing and 1 partial ⚠️
src/vcspull/cli/discover.py 50.00% 1 Missing and 1 partial ⚠️
src/vcspull/cli/fmt.py 60.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #564      +/-   ##
==========================================
+ Coverage   83.30%   83.99%   +0.68%     
==========================================
  Files          32       34       +2     
  Lines        4492     4716     +224     
  Branches      903      934      +31     
==========================================
+ Hits         3742     3961     +219     
- Misses        499      506       +7     
+ Partials      251      249       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant