Skip to content
Open
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
9 changes: 5 additions & 4 deletions docs/cli/add.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,12 @@ change.

## Choosing configuration files

vcspull searches for configuration files in this order:
vcspull resolves configuration from several scopes at once — system, user,
and the `.vcspull.*` files above your working directory. See
{ref}`config-scopes` for the full order.

1. `./.vcspull.yaml`
2. `~/.vcspull.yaml`
3. `~/.config/vcspull/*.yaml`
`add` writes to your user configuration by default, and falls back to
creating `./.vcspull.yaml` when you have none.

Specify a file explicitly with `-f/--file`:

Expand Down
58 changes: 58 additions & 0 deletions docs/cli/config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
(cli-config)=

# vcspull config

vcspull rarely reads just one file. `vcspull config ls` answers the question
that follows from that: standing here, which files are in effect, and which
one wins? Reach for it when `vcspull sync` picks up a repository you did not
expect, or refuses one you did.

## Command

```{eval-rst}
.. argparse::
:module: vcspull.cli
:func: create_parser
:prog: vcspull
:path: config
```

## Reading the report

```vcspull-console
$ vcspull config ls
user ~/.vcspull.yaml 2 repos (1 overridden)
project ~/work/proj/.vcspull.yaml 1 repo

2 repositories in effect.
```

Rows run weakest to strongest, the same order
{ref}`the scopes resolve in <config-scopes>`. The count is what each file
declares; "overridden" is how many of those a nearer file replaced. The final
line is the total you will actually sync.

## Diagnosing a refusal

A project config that would check a repository out beyond its own directory
needs {ref}`trust <cli-trust>` before it loads. `config ls` shows it rather
than dropping it, and never prompts — this is the command you run *because*
something else refused:

```vcspull-output
user ~/.vcspull.yaml 2 repos
project ~/work/proj/.vcspull.yaml 1 repo
project ~/work/proj/escaping/.vcspull.yaml 1 repo (untrusted)

3 repositories in effect.
Untrusted configs are not loaded. Allow one with 'vcspull trust DIR'.
```

## Ignoring the project scope

```console
$ vcspull config ls --no-project
```

Both `vcspull --no-project config ls` and `vcspull config ls --no-project`
work.
16 changes: 15 additions & 1 deletion docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@
{ref}`vcspull worktree <cli-worktree>` manages git worktrees declaratively.
:::

:::{grid-item-card} Config
:link: config
:link-type: doc
{ref}`vcspull config <cli-config>` shows which config files are in effect.
:::

:::{grid-item-card} Trust
:link: trust
:link-type: doc
{ref}`vcspull trust <cli-trust>` allows a project config to act outside its directory.
:::

:::{grid-item-card} Completion
:link: completion
:link-type: doc
Expand All @@ -89,6 +101,8 @@ status
worktree/index
fmt
migrate
config
trust
```

```{toctree}
Expand All @@ -113,5 +127,5 @@ completion
:no-description:

subparser_name : @replace
See :ref:`cli-sync`, :ref:`cli-add`, :ref:`cli-import`, :ref:`cli-discover`, :ref:`cli-list`, :ref:`cli-search`, :ref:`cli-status`, :ref:`cli-worktree`, :ref:`cli-fmt`, :ref:`cli-migrate`
See :ref:`cli-sync`, :ref:`cli-add`, :ref:`cli-import`, :ref:`cli-discover`, :ref:`cli-list`, :ref:`cli-search`, :ref:`cli-status`, :ref:`cli-worktree`, :ref:`cli-fmt`, :ref:`cli-migrate`, :ref:`cli-config`, :ref:`cli-trust`
```
7 changes: 4 additions & 3 deletions docs/cli/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,11 @@ $ vcspull list --ndjson | grep 'study' | jq -r '.name'

## Choosing configuration files

By default, vcspull searches for config files in standard locations
(`~/.vcspull.yaml`, `./.vcspull.yaml`, and
By default, `list` unions every configuration scope in effect: system, your
[XDG](https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html)
config directories).
config directory, `~/.vcspull.yaml`, and any `.vcspull.*` file above your
working directory. See {ref}`config-scopes` for the resolution order, or run
`vcspull config ls` to see what applies where you are standing.

Specify a custom config file with `-f/--file`:

Expand Down
54 changes: 54 additions & 0 deletions docs/cli/trust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
(cli-trust)=

# vcspull trust

A `.vcspull.yaml` inside a repository you cloned is code somebody else wrote,
and it names paths on your disk. vcspull loads such a file silently as long as
every repository it declares lands inside that file's own directory. When one
does not, vcspull asks — and `vcspull trust` is how you answer ahead of time,
or take the answer back.

Most people never run this command. See {ref}`config-scopes` for when the
question comes up at all.

## Command

```{eval-rst}
.. argparse::
:module: vcspull.cli
:func: create_parser
:prog: vcspull
:path: trust
```

## Allowing a project

```vcspull-console
$ vcspull trust ~/work/api
trusted ~/work/api
```

Trust is recorded per *directory*, not per file, so a second config in the
same project does not ask again. The trade-off is real: a configuration
committed to that directory can now direct your checkouts anywhere on disk,
including after somebody else edits it.

## Reviewing and revoking

```vcspull-console
$ vcspull trust --show
~/work/api
```

```vcspull-console
$ vcspull trust --untrust ~/work/api
untrusted ~/work/api
```

The record lives in `$XDG_STATE_HOME/vcspull/trusted`, one directory per line.

## In automation

There is no prompt without a terminal — a sync in CI fails loudly rather than
blocking on a question nobody can see. To accept without asking, pass
`--trust-project` or export `VCSPULL_YES=1`.
11 changes: 11 additions & 0 deletions docs/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ Most users can start with one `~/.vcspull.yaml` file:
::::{grid} 1 1 2 2
:gutter: 2 2 3 3

:::{grid-item-card} Configuration scopes
:link: scopes
:link-type: doc
Where vcspull looks, in what order, and how project configs are trusted.
:::

:::{grid-item-card} Config Generation
:link: generation
:link-type: doc
Expand Down Expand Up @@ -51,6 +57,10 @@ You can place the file in one of three places:
3. Anywhere (and trigger via {ref}`vcspull sync <cli-sync>` with
`--file ./path/to/file.yaml [repo_name]`)

vcspull reads all of these, plus a `.vcspull.yaml` in your project, and
resolves them in a fixed order. See {ref}`config-scopes` for that order, how
overlapping entries merge, and when a project config asks before it acts.

[xdg]: https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html

## Schema
Expand Down Expand Up @@ -369,5 +379,6 @@ git+ssh://git@github.com/tony/kaptan.git
```{toctree}
:hidden:

scopes
generation
```
178 changes: 178 additions & 0 deletions docs/configuration/scopes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
(config-scopes)=

# Configuration scopes

vcspull reads more than one configuration file. Where each file sits decides
how much it can say: a file in `/etc` speaks for the machine, a file in your
home directory speaks for you, and a `.vcspull.yaml` inside a project speaks
for that project. vcspull calls these *scopes*, resolves them in a fixed
order, and unions them into one set of repositories.

If you keep a single `~/.vcspull.yaml`, that is the whole story and you can
stop reading. Nothing below changes what one file does.

## The search order

Weakest to strongest. Every scope that exists contributes, and a repository
named by more than one scope comes from the strongest.

| Scope | Where | Trust |
| --- | --- | --- |
| system | `/etc/vcspull/*.{yaml,yml,json}` | automatic |
| user directory | `$VCSPULL_CONFIGDIR`, else `$XDG_CONFIG_HOME/vcspull/`, else `~/.config/vcspull/`, else `~/.vcspull/` — the first that exists | automatic |
| user dotfile | `~/.vcspull.yaml` or `~/.vcspull.json` | automatic |
| project | `.vcspull.{yaml,yml,json}` in each ancestor of your working directory, outermost first | gated |

Passing `-f/--file` replaces that entire stack with the one file you name, so
existing scripts and CI jobs behave exactly as they always have.

To see what applies where you are standing:

```vcspull-console
$ vcspull config ls
user ~/.vcspull.yaml 2 repos (1 overridden)
project ~/work/proj/.vcspull.yaml 1 repo

2 repositories in effect.
```

Each row is a file in the stack, weakest first, with the count it declares.
"overridden" is how many of those a nearer file replaced, so the last line is
what you will actually sync.

## The upward walk

The project scope walks up from your working directory, so
`~/work/.vcspull.yaml` covers everything under `~/work` — this is what makes
a workspace-of-workspaces manifest usable, and why `vcspull sync` no longer
does nothing from a subdirectory.

The walk stops at `$HOME` or the filesystem root, whichever comes first. A
`.vcspull.yaml` sitting in `/home` or `/` is far likelier to be an accident
than an intent, and the system scope already covers the machine-wide case.
Override the stop set with `VCSPULL_CEILING_PATHS`, a `:`-separated list of
directories.

To ignore the project scope entirely for one command:

```console
$ vcspull --no-project sync --all
```

Export `VCSPULL_NO_PROJECT=1` to make that the default for a shell.

## How the scopes merge

The identity of a repository is its destination on disk — the workspace root
plus the repository name. Two files that name different destinations union;
a project config adds repositories to your user set.

When two files name the same destination, the stronger scope replaces the
entry *whole*. The URL, the VCS, and everything under `options:` come from
the winner. That is deliberate rather than convenient: a project config that
repoints `flask` at a fork must not silently inherit the `rev:` pin from your
user config and check out a revision that does not exist in the fork.

vcspull reports the override only when the two entries actually disagree on
URL or VCS:

```vcspull-output
~/work/proj/.vcspull.yaml overrides ~/.vcspull.yaml for '~/work/proj/vendor/flask' (url or vcs differs)
```

Identical entries across a user and a project file are the common, harmless
case, and stay silent. If every sync warned about them, you would learn to
ignore the channel.

Replacing the whole entry costs you granularity: a project file cannot tweak
one field of an inherited entry. To change anything about a repository your
user config already names, the project file restates that entry in full.

## Trusting a project config

A configuration is a set of destination paths, so a `.vcspull.yaml` inside a
repository you just cloned could name `~/.ssh/` as a workspace root and have
`vcspull sync` clone into it. The project scope is the only scope that can
arrive with untrusted code, so it is the only one vcspull gates.

The gate is containment: a project config is *contained* when every
repository it declares would be checked out at or beneath the directory
holding that config. The check runs over destinations, not over the workspace
roots you wrote — a repository entry can override its destination with
`path:` and never consult its workspace root at all — and it expands `~`,
environment variables, and symlinks on both sides first. A config targeting
`./vendor/` or a sibling checkout is contained, and loads silently. This is
the overwhelming majority of them, which is what keeps the gate from becoming
a nag.

A config that escapes its own directory asks once:

```vcspull-output
! ~/work/proj/escaping/.vcspull.yaml would check repositories out outside its directory:
~/.ssh/evil
Trust this config? [y/N/always]
```

Answering `always` records the project *directory*, not the file, so adding a
second config to a project you already trust does not ask again. The record
lives in `$XDG_STATE_HOME/vcspull/trusted`.

Without a terminal there is no prompt — vcspull fails with the whole remedy in
one line, because a sync in CI must never block on a question nobody can see:

```vcspull-output
vcspull: ~/work/proj/escaping/.vcspull.yaml would check repositories out outside its directory (~/.ssh/evil) and there is no terminal to confirm on. Run 'vcspull trust ~/work/proj/escaping' to allow it, pass --trust-project, or use --no-project to skip project configs.
```

When you do want to accept non-interactively, pass `--trust-project` or export
`VCSPULL_YES=1`. That switch is deliberately its own word rather than a
general `--yes`: an unrelated confirmation flag must never become a standing
grant to write outside a directory.

Trusting a directory is not free. It means a configuration committed to that
directory can direct your checkouts anywhere on disk, including next time,
when somebody else has edited it. Trust the project, not just the file you
read.

The same check guards the commands that write: {ref}`add <cli-add>`,
{ref}`discover <cli-discover>`, {ref}`fmt <cli-fmt>`, and
{ref}`migrate <cli-migrate>`. A repository that ships a config redirecting
your writes deserves the same question as one redirecting your clones. Naming
a file with `-f/--file` is consent and skips the gate, the same way `--file`
skips the scope stack on the read side.

Manage the record directly with {ref}`vcspull trust <cli-trust>`:

```console
$ vcspull trust ~/work/api
```

```console
$ vcspull trust --untrust ~/work/api
```

```console
$ vcspull trust --show
```

## For the Python reader

Everything above is one function. {func}`vcspull.config.load_scoped_configs`
resolves the stack, merges it, and runs the trust gate, returning the same
list of entries the CLI syncs:

```python
from vcspull.config import load_scoped_configs

repos = load_scoped_configs()
```

Pass `config_path` to replace the stack the way `--file` does,
`include_project=False` for `--no-project`, or `cwd` to resolve as if you were
standing somewhere else.

Two lower-level pieces are worth knowing if you are building on this.
{func}`vcspull.config.repo_destinations` answers "where would this check
out?" for a list of entries, honouring `path:` overrides — it is what the
containment check reads. {func}`vcspull.config.ensure_config_trusted` is the
gate the write commands call, and returns `False` when the user declines.
Loading