Skip to content

Commit f6ccdcf

Browse files
authored
cli(add) Accept a repository URL (#563)
`vcspull add` now takes a repository URL in place of a filesystem path, so a repository can be recorded before it is cloned; the entry lands in the configuration and `vcspull sync` clones it on the next run. An existing directory still takes precedence, so adding a checkout by path is unchanged. The name comes from the URL, with `--name` required when a URL has no path segment to name. A pip-style revision on a `git+` URL is recorded as `options.rev` rather than left in the URL, and passing `--pin` as well is an error. This restores declaring an un-cloned repository, which the path-based rework in v1.43.0 removed. Verification: - uv run ruff format . --check; uv run ruff check .; uv run mypy; uv run py.test; just build-docs; - every documented `vcspull add` invocation executed in an isolated HOME; - add-by-URL through to `vcspull sync` cloning and checking out the pinned tag.
2 parents 049489a + 57dd77f commit f6ccdcf

9 files changed

Lines changed: 1530 additions & 110 deletions

File tree

CHANGES

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,31 @@ $ uvx --from 'vcspull' --prerelease allow vcspull
3838
_Notes on upcoming releases will be added here_
3939
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
4040

41+
### What's new
42+
43+
#### Add a repository by URL (#563)
44+
45+
{ref}`cli-add` now accepts a repository URL in place of a filesystem path, so you
46+
can record a repository you have not cloned yet: the entry lands in your
47+
configuration and {ref}`vcspull sync <cli-sync>` clones it on the next run. The
48+
name comes from the URL — `--name` supplies one when the URL has no path segment
49+
to name — and because there is no parent directory to infer a workspace from,
50+
vcspull offers the workspace roots your configuration already declares, with
51+
`--workspace` to name one outright. An existing directory still takes
52+
precedence, so adding a checkout by path is unchanged.
53+
54+
A pip-style revision on a `git+` URL is recorded as `options.rev` rather than
55+
left in the URL, so `git+https://github.com/pallets/flask.git@v1.0` stores
56+
`flask` pinned to `v1.0`. Passing `--pin` as well is an error, and a URL shape
57+
that cannot carry a revision asks you to use `--pin` instead.
58+
59+
### Fixes
60+
61+
- Declaring a repository you have not cloned works again. From v1.43.0 the
62+
migration documented for `vcspull import <name> <url>` failed with
63+
`Repository path ... does not exist` unless you created an empty directory
64+
first. (#563)
65+
4166
## vcspull v1.65.0 (2026-07-05)
4267

4368
vcspull v1.65.0 sharpens the feedback you get when a sync cannot check out

docs/cli/add.md

Lines changed: 145 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
# vcspull add
44

5-
The `vcspull add` command registers a repository in your
6-
{ref}`configuration <configuration>` by pointing vcspull at a checkout on
7-
disk. The command inspects the directory,
8-
merges duplicate workspace roots by default, and prompts before writing unless
9-
you pass `--yes`.
5+
The `vcspull add` command registers a single repository in your
6+
{ref}`configuration <configuration>`. Point it at a checkout on disk and it
7+
reads the details out of the directory; give it a repository URL and it records
8+
the entry without cloning anything, leaving the working tree to
9+
{ref}`vcspull sync <cli-sync>`. Either way it merges duplicate workspace roots
10+
by default and prompts before writing unless you pass `--yes`.
1011

1112
```{note}
1213
This command replaces the old `vcspull import <name> <url>` from v1.36--v1.39.
@@ -43,6 +44,56 @@ The parent directory (`~/study/python/` in this example) becomes the workspace
4344
root. vcspull shortens paths under `$HOME` to `~/...` in its log output so the
4445
preview stays readable.
4546

47+
## Declaring a repository you have not cloned
48+
49+
Pass a repository URL instead of a path when you want the entry in your
50+
configuration but do not have the code yet:
51+
52+
```vcspull-console
53+
$ vcspull add https://github.com/pallets/flask.git
54+
Found new repository to import:
55+
+ flask (https://github.com/pallets/flask.git)
56+
• workspace roots in ~/.vcspull.yaml:
57+
1) ~/code/ (default)
58+
2) ~/study/python/
59+
? Import this repository? [y/N/1-2]: y
60+
• workspace: ~/code/
61+
↳ path: ~/code/flask
62+
✓ Successfully added 'flask' (git+https://github.com/pallets/flask.git) to ~/.vcspull.yaml under '~/code/'.
63+
```
64+
65+
The repository name comes from the URL — `flask` here — unless you pass
66+
`--name`. A URL with no path to name, such as `https://host/.git`, stops with an
67+
error asking for `--name`. Nothing is fetched: the entry lands in your
68+
configuration and {ref}`vcspull sync <cli-sync>` clones it the next time you run
69+
it.
70+
71+
Because there is no parent directory to infer a workspace from, vcspull offers
72+
the workspace roots your configuration already declares. Answering `y` accepts
73+
the default, answering with a number picks a different root, and `--workspace`
74+
names one outright and skips the list. When the configuration declares no roots
75+
yet, the current directory becomes the workspace. The workspace and path are
76+
reported once your answer settles them, so what you see named is the section the
77+
entry is written under.
78+
79+
### Pinning a revision from the URL
80+
81+
A [pip-style][pip vcs url] revision on a `git+` URL is recorded as
82+
`options.rev` rather than kept in the URL:
83+
84+
```console
85+
$ vcspull add git+https://github.com/pallets/flask.git@v1.0
86+
```
87+
88+
That stores `flask` with `rev: v1.0`. Because `--pin` records the same field,
89+
passing both is an error. Only `git+` URLs carry a revision this way — on a
90+
plain `https://` or scp-style URL the revision cannot be told apart from the
91+
path, so vcspull asks you to pass `--pin` instead of recording a URL that will
92+
not clone.
93+
94+
A directory on disk always wins. If the argument names something that exists,
95+
vcspull treats it as a path even when the same text would also parse as a URL.
96+
4697
## Overriding detected information
4798

4899
### Choose a different name
@@ -54,6 +105,13 @@ isn't the label you want stored in the configuration:
54105
$ vcspull add ~/study/python/pytest-docker --name docker-pytest
55106
```
56107

108+
`--name` is required, rather than optional, when a URL carries no path segment
109+
to name — vcspull stops instead of writing an entry you could not address:
110+
111+
```console
112+
$ vcspull add https://git.example.com/.git --name internal-tools
113+
```
114+
57115
### Override the remote URL
58116

59117
vcspull reads the [Git](https://git-scm.com/) `origin` remote automatically. Supply `--url` when you
@@ -63,20 +121,61 @@ need to register a different remote or when the checkout does not have one yet:
63121
$ vcspull add ~/study/python/example --url https://github.com/org/example
64122
```
65123

124+
`--url` accompanies a path. When the argument is already a URL, pass it once and
125+
leave `--url` off — supplying both is ambiguous, so vcspull stops rather than
126+
guessing which one you meant.
127+
66128
URLs follow [pip's VCS format][pip vcs url]; vcspull inserts the `git+` prefix
67129
for HTTPS URLs so the resulting configuration matches
68130
{ref}`vcspull fmt <cli-fmt>` output.
69131

70132
### Select a workspace explicitly
71133

72-
The workspace defaults to the checkout's parent directory. Pass
134+
The workspace defaults to the checkout's parent directory, or — when you add by
135+
URL — to the first workspace root your configuration declares. Pass
73136
`--workspace`/`--workspace-root` to store the repository under a different
74137
section:
75138

76139
```console
77140
$ vcspull add ~/scratch/tmp-project --workspace ~/projects/python/
78141
```
79142

143+
Naming a workspace also skips the list of declared roots you would otherwise be
144+
offered when adding by URL:
145+
146+
```console
147+
$ vcspull add https://github.com/pallets/flask.git --workspace ~/code/
148+
```
149+
150+
### Record a revision or clone depth
151+
152+
By default an entry tracks its remote's default branch and clones with full
153+
history. Three flags change that, and each one costs you something in exchange.
154+
155+
Pin the entry to a fixed commit, tag, or branch with `--pin`, which records
156+
{ref}`options.rev <config-pin>`. The repository stops following its branch until
157+
you change the pin:
158+
159+
```console
160+
$ vcspull add ~/study/python/flask --pin v3.0.0
161+
```
162+
163+
`--shallow` records `options.shallow: true`, so {ref}`vcspull sync <cli-sync>`
164+
clones with `--depth 1`. That trades git history for disk and time — useful
165+
across many repositories, awkward if you later need `git log` or `git bisect`.
166+
An already-shallow checkout is detected without the flag; this forces it on:
167+
168+
```console
169+
$ vcspull add ~/study/python/django --shallow
170+
```
171+
172+
When depth 1 is too little, `--depth N` keeps a window of history instead.
173+
It overrides `--shallow` when both are given:
174+
175+
```console
176+
$ vcspull add ~/study/python/django --depth 50
177+
```
178+
80179
## Confirmation and dry runs
81180

82181
`vcspull add` asks for confirmation before writing. Use `--yes` to skip the
@@ -90,6 +189,15 @@ $ vcspull add ~/study/python/pytest-docker --dry-run
90189
Dry runs still show duplicate merge diagnostics so you can see what would
91190
change.
92191

192+
`--yes` answers both prompts for you — the confirmation and, when adding by URL,
193+
the workspace-root list — which is what you want from a script:
194+
195+
```console
196+
$ vcspull add https://github.com/pallets/flask.git \
197+
--workspace ~/code/ \
198+
--yes
199+
```
200+
93201
## Choosing configuration files
94202

95203
vcspull searches for configuration files in this order:
@@ -110,7 +218,14 @@ $ vcspull add ~/study/python/pytest-docker \
110218
vcspull merges duplicate workspace sections before writing so existing
111219
repositories stay intact. When it collapses multiple sections, the command logs
112220
a summary of the merge. Prefer to inspect duplicates yourself? Add
113-
`--no-merge` to keep every section untouched.
221+
`--no-merge` to keep every section untouched:
222+
223+
```console
224+
$ vcspull add ~/study/python/pytest-docker --no-merge
225+
```
226+
227+
The entry is still written; only the merging of repeated workspace roots is
228+
skipped, and each duplicate is reported as a warning instead.
114229

115230
## Pinned entries
116231

@@ -126,14 +241,22 @@ Repositories whose configuration includes a {ref}`pin <config-pin>` on the
126241
pin_reason: "pinned to company fork — update manually"
127242
```
128243
129-
Attempting to add a repo that matches an existing pinned entry produces a
130-
warning and leaves the entry untouched:
244+
Attempting to add a repo that matches an existing pinned entry previews it as
245+
usual, then warns and leaves the entry untouched:
131246
132247
```vcspull-console
133248
$ vcspull add ~/code/internal-fork
134-
⚠ Repository 'internal-fork' is pinned (pinned to company fork — update manually) — skipping
249+
Found new repository to import:
250+
+ internal-fork (git@github.com:myorg/internal-fork.git)
251+
• workspace: ~/code/
252+
↳ path: ~/code/internal-fork
253+
? Import this repository? [y/N]: y
254+
Repository 'internal-fork' is pinned (pinned to company fork — update manually) — skipping
135255
```
136256

257+
The pin is checked when the entry is written, not when the preview is built, so
258+
confirming still reaches the warning rather than skipping the prompt.
259+
137260
Both `options.pin: true` (global) and `options.pin.add: true` (per-operation)
138261
block the `add` command. The `pin_reason` (if set) is included in the warning.
139262
See {ref}`config-pin` for full pin configuration.
@@ -152,15 +275,22 @@ by `vcspull add`:
152275

153276
```diff
154277
- $ vcspull import flask https://github.com/pallets/flask.git -c ~/.vcspull.yaml
155-
+ $ vcspull add ~/code/flask --url https://github.com/pallets/flask.git --file ~/.vcspull.yaml
278+
+ $ vcspull add https://github.com/pallets/flask.git --file ~/.vcspull.yaml
156279
```
157280

158281
Key differences:
159282

160-
- `vcspull add` derives the name from the filesystem unless you pass `--name`.
161-
- The parent directory becomes the workspace automatically; use `--workspace`
162-
to override.
163-
- Use `--url` to record a remote when the checkout does not have one.
283+
- `vcspull add` derives the name from the URL, or from the directory when you
284+
add a checkout, unless you pass `--name`.
285+
- The workspace comes from the checkout's parent directory, or from the
286+
workspace roots your configuration declares when you add by URL; use
287+
`--workspace` to override either.
288+
- Use `--url` to record a remote when a checkout does not have one.
289+
290+
If you tried this migration between v1.43.0 and v1.65.0, it did not work: `add`
291+
required the repository to be checked out already, so a URL — or a path you had
292+
not cloned — stopped with `Repository path ... does not exist`. Passing the URL
293+
straight to `add`, as above, now does what the old command did.
164294

165295
```{note}
166296
Starting with v1.55, `vcspull import` is a *different* command that bulk-imports

docs/conf.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import pathlib
6+
import re
67
import sys
78
import typing as t
89

@@ -54,10 +55,82 @@
5455

5556
_gp_setup = conf.pop("setup")
5657

58+
_NUMPY_UNDERLINE = re.compile(r"^\s*-{2,}\s*$")
59+
60+
61+
def _numpy_attribute_names(doc: str | None) -> frozenset[str]:
62+
"""Return field names a NumPy ``Attributes`` section of *doc* documents."""
63+
if not doc:
64+
return frozenset()
65+
66+
lines = doc.expandtabs().splitlines()
67+
names: set[str] = set()
68+
index = 0
69+
while index + 1 < len(lines):
70+
heading = lines[index].strip() == "Attributes"
71+
if not (heading and _NUMPY_UNDERLINE.match(lines[index + 1])):
72+
index += 1
73+
continue
74+
75+
indent = len(lines[index]) - len(lines[index].lstrip())
76+
cursor = index + 2
77+
while cursor < len(lines):
78+
entry = lines[cursor]
79+
if not entry.strip():
80+
cursor += 1
81+
continue
82+
entry_indent = len(entry) - len(entry.lstrip())
83+
if entry_indent < indent:
84+
break
85+
if entry_indent == indent:
86+
# The next NumPy section header is underlined; stop before it.
87+
if cursor + 1 < len(lines) and _NUMPY_UNDERLINE.match(
88+
lines[cursor + 1]
89+
):
90+
break
91+
names.add(entry.split(":", 1)[0].strip())
92+
cursor += 1
93+
index = cursor
94+
95+
return frozenset(names)
96+
97+
98+
def _skip_documented_namedtuple_fields(
99+
app: Sphinx,
100+
what: str,
101+
name: str,
102+
obj: object,
103+
skip: bool,
104+
options: object,
105+
) -> bool | None:
106+
"""Drop NamedTuple field stubs the class docstring already documents.
107+
108+
``typing.NamedTuple`` fields are descriptors whose ``__doc__`` is
109+
``"Alias for field number N"``. Autodoc counts that boilerplate as a real
110+
docstring, so the field is documented no matter how ``undoc-members`` is
111+
set. When the class docstring carries a NumPy ``Attributes`` section, the
112+
docstring preprocessor has already emitted an ``.. attribute::`` block for
113+
the same dotted name, and the Python domain warns about the duplicate.
114+
"""
115+
if skip or what != "class":
116+
return None
117+
118+
current = app.env.current_document
119+
module = sys.modules.get(getattr(current, "autodoc_module", "") or "")
120+
owner_name = (getattr(current, "autodoc_class", "") or "").partition(".")[0]
121+
owner = getattr(module, owner_name, None)
122+
123+
fields = getattr(owner, "_fields", None)
124+
if not isinstance(fields, tuple) or name not in fields:
125+
return None
126+
127+
return name in _numpy_attribute_names(owner.__doc__)
128+
57129

58130
def setup(app: Sphinx) -> None:
59131
"""Configure Sphinx app hooks and register vcspull-specific lexers."""
60132
_gp_setup(app)
133+
app.connect("autodoc-skip-member", _skip_documented_namedtuple_fields)
61134

62135
from vcspull_console_lexer import VcspullConsoleLexer
63136
from vcspull_output_lexer import VcspullOutputLexer

src/vcspull/cli/__init__.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,28 @@ def build_description(
203203
None,
204204
[
205205
"vcspull add ~/code/mylib",
206+
"vcspull add https://github.com/example/mylib.git",
206207
"vcspull add ~/src/mylib --workspace ~/code",
207-
(
208-
"vcspull add ~/code/mylib "
209-
"--url https://github.com/example/mylib.git --dry-run"
210-
),
208+
"vcspull add https://git.example.com/.git --name mylib",
209+
("vcspull add ~/code/mylib --url https://github.com/example/mylib.git"),
210+
],
211+
),
212+
(
213+
"Pinning",
214+
[
215+
"vcspull add ~/code/mylib --pin v1.0",
216+
"vcspull add git+https://github.com/example/mylib.git@v1.0",
217+
"vcspull add ~/code/mylib --shallow",
218+
"vcspull add ~/code/mylib --depth 50",
219+
],
220+
),
221+
(
222+
"Automation",
223+
[
224+
"vcspull add ~/code/mylib --dry-run",
225+
"vcspull add https://github.com/example/mylib.git --yes",
226+
"vcspull add ~/code/mylib --file ~/configs/python.yaml",
227+
"vcspull add ~/code/mylib --no-merge",
211228
],
212229
),
213230
),

0 commit comments

Comments
 (0)