From 6be04711878e5613b0c9b8cef7da4373ee734b95 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Jul 2026 18:11:50 -0500 Subject: [PATCH 1/3] cli(add[url]) Accept a URL as the add argument why: Declaring a repository that is not checked out yet required hand-editing the YAML. `vcspull add` gated on the path existing, and `--url` only overrode the remote detected from a checkout, so there was no CLI route from "I know the URL" to a config entry. URL-first adding existed as `vcspull import ` through v1.39 and was dropped when `add` became path-first; `import` now belongs to the forge importers, so the capability comes back on `add` instead. what: - Accept either a filesystem path or a repository URL as the `add` argument, discriminated with libvcs `GitURL.is_valid` - Resolve an existing directory before considering a URL, so every path-mode invocation keeps its current behavior - Derive the repo name from the URL basename, still overridable with `--name`; reject a URL argument combined with `--url` - Offer workspace roots already declared in the config when a URL has no `--workspace`, defaulting to the first and falling back to the current directory; fold the choice into the existing confirm prompt - Extract config-file resolution into `_resolve_config_file` so the prompt can read declared roots before the write path runs - Cover URL forms, root selection, decline paths, and path-wins precedence in tests/cli/test_add.py --- src/vcspull/cli/add.py | 350 ++++++++++++++++++++++++++++++++++------- tests/cli/test_add.py | 292 ++++++++++++++++++++++++++++++++++ 2 files changed, 586 insertions(+), 56 deletions(-) diff --git a/src/vcspull/cli/add.py b/src/vcspull/cli/add.py index 849b1bac..c39b0750 100644 --- a/src/vcspull/cli/add.py +++ b/src/vcspull/cli/add.py @@ -12,6 +12,7 @@ import typing as t from colorama import Fore, Style +from libvcs.url.git import GitURL from vcspull._internal.config_reader import ( DuplicateAwareConfigReader, @@ -99,8 +100,10 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None: nargs="?", default=None, help=( - "Filesystem path to an existing project. The parent directory " - "becomes the workspace unless overridden with --workspace." + "Filesystem path to an existing project, or a repository URL to " + "declare without checking it out. A path's parent directory " + "becomes the workspace; a URL uses --workspace, else a workspace " + "root already declared in the config." ), ) parser.add_argument( @@ -111,7 +114,10 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--url", dest="url", - help="Repository URL to record (overrides detected remotes)", + help=( + "Repository URL to record for a path (overrides detected remotes). " + "Omit when the argument is already a URL." + ), ) parser.add_argument( "--pin", @@ -250,6 +256,168 @@ def _normalize_detected_url(remote: str | None) -> tuple[str, str]: return display_url, config_url +def _repo_name_from_url(url: str) -> str: + """Derive a repository name from a VCS URL. + + Strips a ``git+`` prefix, trailing slashes, and a ``.git`` suffix, then + takes the final path segment. Handles scp-style remotes, which have no + ``/`` separating host from path. + + Parameters + ---------- + url : str + Repository URL to derive a name from. + + Returns + ------- + str + Repository name suitable as a config key. + + Examples + -------- + >>> _repo_name_from_url("https://github.com/pallets/flask.git") + 'flask' + + >>> _repo_name_from_url("git+https://github.com/pallets/flask.git") + 'flask' + + Scp-style remotes: + + >>> _repo_name_from_url("git@github.com:pallets/flask.git") + 'flask' + + A trailing slash and a missing ``.git`` suffix are both tolerated: + + >>> _repo_name_from_url("https://github.com/pallets/flask/") + 'flask' + """ + cleaned = url.strip() + if cleaned.startswith("git+"): + cleaned = cleaned[len("git+") :] + cleaned = cleaned.rstrip("/") + + tail = cleaned.rsplit("/", 1)[-1] + if "/" not in tail and ":" in tail: + tail = tail.rsplit(":", 1)[-1] + if tail.endswith(".git"): + tail = tail[: -len(".git")] + return tail + + +class ConfigFileResolution(t.NamedTuple): + """Outcome of deciding which config file ``add`` should write to.""" + + path: pathlib.Path | None + creates_new_default: bool + ambiguous: bool + + +def _resolve_config_file(config_file_path_str: str | None) -> ConfigFileResolution: + """Resolve which config file ``add`` should write to. + + Reports discovery outcomes rather than logging them, so callers that only + need the path (to read declared workspace roots, say) do not emit the + discovery messages a second time. + + Parameters + ---------- + config_file_path_str : str | None + Value of ``-f/--file``, or ``None`` to discover a default. + + Returns + ------- + ConfigFileResolution + ``path`` is ``None`` only when ``ambiguous`` is ``True``. + """ + if config_file_path_str: + return ConfigFileResolution( + path=normalize_config_file_path(pathlib.Path(config_file_path_str)), + creates_new_default=False, + ambiguous=False, + ) + + home_configs = find_home_config_files(filetype=["yaml"]) + if not home_configs: + return ConfigFileResolution( + path=pathlib.Path.cwd() / ".vcspull.yaml", + creates_new_default=True, + ambiguous=False, + ) + if len(home_configs) > 1: + return ConfigFileResolution( + path=None, + creates_new_default=False, + ambiguous=True, + ) + return ConfigFileResolution( + path=home_configs[0], + creates_new_default=False, + ambiguous=False, + ) + + +def _declared_workspace_labels(config_file_path: pathlib.Path) -> list[str]: + r"""Return workspace root labels already declared in a config file. + + Used to offer the workspace roots a user already keeps repositories under + when adding by URL, where there is no parent directory to infer from. + Duplicate labels collapse to their first occurrence, preserving file order. + + Parameters + ---------- + config_file_path : pathlib.Path + Config file to inspect. A missing or unreadable file yields ``[]``. + + Returns + ------- + list[str] + Workspace root labels in the order they appear in the file. + + Examples + -------- + A missing file has no declared roots: + + >>> _declared_workspace_labels(tmp_path / "absent.yaml") + [] + + Labels come back in file order, without duplicates: + + >>> config_file = tmp_path / "declared.yaml" + >>> _ = config_file.write_text( + ... "~/code/:\n a: git+https://example.com/a.git\n" + ... "~/study/:\n b: git+https://example.com/b.git\n", + ... encoding="utf-8", + ... ) + >>> _declared_workspace_labels(config_file) + ['~/code/', '~/study/'] + """ + if not (config_file_path.exists() and config_file_path.is_file()): + return [] + + try: + ( + raw_config, + _duplicates, + top_level_items, + ) = DuplicateAwareConfigReader.load_with_duplicates(config_file_path) + except Exception: + log.debug( + "Could not read workspace roots from %s", + PrivatePath(config_file_path), + exc_info=True, + ) + return [] + + source = top_level_items or list(raw_config.items()) + labels: list[str] = [] + seen: set[str] = set() + for label, section in source: + if isinstance(section, dict) and label not in seen: + seen.add(label) + labels.append(label) + return labels + + def _build_ordered_items( top_level_items: list[tuple[str, t.Any]] | None, raw_config: dict[str, t.Any], @@ -386,56 +554,97 @@ def handle_add_command(args: argparse.Namespace) -> None: """Entry point for the ``vcspull add`` CLI command.""" repo_input = getattr(args, "repo_path", None) if repo_input is None: - log.error("A repository path must be provided.") + log.error("A repository path or URL must be provided.") return cwd = pathlib.Path.cwd() repo_path = expand_dir(pathlib.Path(repo_input), cwd=cwd) + explicit_url = getattr(args, "url", None) - if not repo_path.exists(): - log.error("Repository path %s does not exist.", PrivatePath(repo_path)) - return + # An existing directory always wins, so every path-mode invocation keeps + # behaving as before; a URL is only considered when nothing is on disk. + url_mode = not repo_path.exists() and GitURL.is_valid(repo_input) + + if not url_mode: + if not repo_path.exists(): + log.error("Repository path %s does not exist.", PrivatePath(repo_path)) + return - if not repo_path.is_dir(): - log.error("Repository path %s is not a directory.", PrivatePath(repo_path)) + if not repo_path.is_dir(): + log.error("Repository path %s is not a directory.", PrivatePath(repo_path)) + return + + resolution = _resolve_config_file(getattr(args, "config", None)) + if resolution.ambiguous or resolution.path is None: + log.error( + "Multiple home config files found, please specify one with -f/--file", + ) return + # Discovery messages stay in add_repo, which owns the write, so resolving + # here to read declared workspace roots does not duplicate them. + config_file_path = resolution.path override_name = getattr(args, "override_name", None) - repo_name = override_name or repo_path.name - explicit_url = getattr(args, "url", None) - if explicit_url: - display_url, config_url = _normalize_detected_url(explicit_url) + if url_mode: + if explicit_url: + log.error( + "Cannot combine a repository URL argument with --url; " + "pass the URL once.", + ) + return + repo_name = override_name or _repo_name_from_url(repo_input) + display_url, config_url = _normalize_detected_url(repo_input) else: - detected_remote = _detect_git_remote(repo_path) - display_url, config_url = _normalize_detected_url(detected_remote) + repo_name = override_name or repo_path.name + if explicit_url: + display_url, config_url = _normalize_detected_url(explicit_url) + else: + detected_remote = _detect_git_remote(repo_path) + display_url, config_url = _normalize_detected_url(detected_remote) - if not config_url: - display_url = str(PrivatePath(repo_path)) - config_url = str(repo_path) - log.warning( - "Unable to determine git remote for %s; using local path in config.", - repo_path, - ) + if not config_url: + display_url = str(PrivatePath(repo_path)) + config_url = str(repo_path) + log.warning( + "Unable to determine git remote for %s; using local path in config.", + repo_path, + ) workspace_root_arg = getattr(args, "workspace_root_path", None) - workspace_root_input = ( - workspace_root_arg - if workspace_root_arg is not None - else repo_path.parent.as_posix() - ) + workspace_candidates: list[str] = [] + + if workspace_root_arg is not None: + workspace_root_input = workspace_root_arg + elif url_mode: + # No parent directory to infer from, so offer the roots this config + # already declares before falling back to the current directory. + workspace_candidates = _declared_workspace_labels(config_file_path) + if workspace_candidates: + workspace_root_input = workspace_candidates[0] + else: + workspace_root_input = workspace_root_label( + cwd, + cwd=cwd, + home=pathlib.Path.home(), + preserve_cwd_label=config_file_path.parent == cwd, + ) + else: + workspace_root_input = repo_path.parent.as_posix() workspace_path = expand_dir(pathlib.Path(workspace_root_input), cwd=cwd) workspace_label = workspace_root_label( workspace_path, cwd=cwd, home=pathlib.Path.home(), - preserve_cwd_label=workspace_root_arg in {".", "./"}, + preserve_cwd_label=workspace_root_input in {".", "./"}, ) summary_url = display_url or config_url - display_path = str(PrivatePath(repo_path)) + display_path = str( + PrivatePath(repo_path if not url_mode else workspace_path / repo_name), + ) log.info("%sFound new repository to import:%s", Fore.GREEN, Style.RESET_ALL) log.info( @@ -504,22 +713,49 @@ def handle_add_command(args: argparse.Namespace) -> None: Style.RESET_ALL, ) - prompt_text = f"{Fore.CYAN}?{Style.RESET_ALL} Import this repository? [y/N]: " + # Offering the choice inline keeps URL mode to a single prompt: confirming + # accepts the default root, a number picks a different declared one. + offer_choice = len(workspace_candidates) > 1 + answers = "[y/N]" if not offer_choice else f"[y/N/1-{len(workspace_candidates)}]" + + if offer_choice: + log.info( + " %s•%s workspace roots in %s%s%s:", + Fore.BLUE, + Style.RESET_ALL, + Fore.BLUE, + PrivatePath(config_file_path), + Style.RESET_ALL, + ) + for index, candidate in enumerate(workspace_candidates, start=1): + log.info( + " %s%d)%s %s%s%s%s", + Fore.YELLOW, + index, + Style.RESET_ALL, + Fore.MAGENTA, + candidate, + Style.RESET_ALL, + " (default)" if index == 1 else "", + ) + + prompt_text = f"{Fore.CYAN}?{Style.RESET_ALL} Import this repository? {answers}: " - proceed = True if args.dry_run: log.info( - "%s?%s Import this repository? [y/N]: %sskipped (dry-run)%s", + "%s?%s Import this repository? %s: %sskipped (dry-run)%s", Fore.CYAN, Style.RESET_ALL, + answers, Fore.YELLOW, Style.RESET_ALL, ) elif getattr(args, "assume_yes", False): log.info( - "%s?%s Import this repository? [y/N]: %sy (auto-confirm)%s", + "%s?%s Import this repository? %s: %sy (auto-confirm)%s", Fore.CYAN, Style.RESET_ALL, + answers, Fore.GREEN, Style.RESET_ALL, ) @@ -528,20 +764,30 @@ def handle_add_command(args: argparse.Namespace) -> None: response = input(prompt_text) except EOFError: response = "" - proceed = response.strip().lower() in {"y", "yes"} - if not proceed: + answer = response.strip().lower() + + chosen: str | None = None + if answer in {"y", "yes"}: + chosen = workspace_root_input + elif offer_choice and answer.isdigit(): + index = int(answer) + if 1 <= index <= len(workspace_candidates): + chosen = workspace_candidates[index - 1] + + if chosen is None: log.info( "Aborted import of '%s' from %s", repo_name, - PrivatePath(repo_path), + repo_input if url_mode else PrivatePath(repo_path), ) return + workspace_root_input = chosen add_repo( name=repo_name, url=config_url, config_file_path_str=args.config, - path=str(repo_path), + path=None if url_mode else str(repo_path), workspace_root_path=workspace_root_input, dry_run=args.dry_run, merge_duplicates=args.merge_duplicates, @@ -588,26 +834,18 @@ def add_repo( If set, record ``options.depth: N`` for the repository. """ # Determine config file - config_file_path: pathlib.Path - if config_file_path_str: - config_file_path = normalize_config_file_path( - pathlib.Path(config_file_path_str) + resolution = _resolve_config_file(config_file_path_str) + if resolution.ambiguous or resolution.path is None: + log.error( + "Multiple home config files found, please specify one with -f/--file", + ) + return + config_file_path = resolution.path + if resolution.creates_new_default: + log.info( + "No config specified and no default found, will create at %s", + PrivatePath(config_file_path), ) - else: - home_configs = find_home_config_files(filetype=["yaml"]) - if not home_configs: - config_file_path = pathlib.Path.cwd() / ".vcspull.yaml" - log.info( - "No config specified and no default found, will create at %s", - PrivatePath(config_file_path), - ) - elif len(home_configs) > 1: - log.error( - "Multiple home config files found, please specify one with -f/--file", - ) - return - else: - config_file_path = home_configs[0] # Load existing config raw_config: dict[str, t.Any] diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index 0e844f19..956569a4 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -1714,3 +1714,295 @@ def test_collapse_ordered_items_to_dict( for label, expected_keys in expected_repo_keys.items(): assert label in result assert set(result[label].keys()) == expected_keys + + +TWO_ROOT_CONFIG = textwrap.dedent( + """\ + ~/code/: + existing: git+https://example.com/existing.git + ~/study/: + other: git+https://example.com/other.git + """, +) + + +class UrlAddFixture(t.NamedTuple): + """Fixture describing CLI URL-mode add scenarios.""" + + test_id: str + repo_url: str + override_name: str | None + workspace_override: str | None + preexisting_yaml: str | None + prompt_response: str | None + assume_yes: bool + expected_name: str + expected_workspace: str + expected_url: str + expected_written: bool + + +URL_ADD_FIXTURES: list[UrlAddFixture] = [ + UrlAddFixture( + test_id="url-defaults-to-first-declared-root", + repo_url="https://github.com/pallets/flask.git", + override_name=None, + workspace_override=None, + preexisting_yaml=TWO_ROOT_CONFIG, + prompt_response=None, + assume_yes=True, + expected_name="flask", + expected_workspace="~/code/", + expected_url="git+https://github.com/pallets/flask.git", + expected_written=True, + ), + UrlAddFixture( + test_id="url-selects-second-declared-root", + repo_url="https://github.com/psf/requests.git", + override_name=None, + workspace_override=None, + preexisting_yaml=TWO_ROOT_CONFIG, + prompt_response="2", + assume_yes=False, + expected_name="requests", + expected_workspace="~/study/", + expected_url="git+https://github.com/psf/requests.git", + expected_written=True, + ), + UrlAddFixture( + test_id="url-declined-writes-nothing", + repo_url="https://github.com/psf/requests.git", + override_name=None, + workspace_override=None, + preexisting_yaml=TWO_ROOT_CONFIG, + prompt_response="n", + assume_yes=False, + expected_name="requests", + expected_workspace="~/code/", + expected_url="git+https://github.com/psf/requests.git", + expected_written=False, + ), + UrlAddFixture( + test_id="url-out-of-range-selection-aborts", + repo_url="https://github.com/psf/requests.git", + override_name=None, + workspace_override=None, + preexisting_yaml=TWO_ROOT_CONFIG, + prompt_response="9", + assume_yes=False, + expected_name="requests", + expected_workspace="~/code/", + expected_url="git+https://github.com/psf/requests.git", + expected_written=False, + ), + UrlAddFixture( + test_id="url-workspace-override-skips-prompt-list", + repo_url="https://github.com/pallets/flask.git", + override_name=None, + workspace_override="~/projects/", + preexisting_yaml=TWO_ROOT_CONFIG, + prompt_response=None, + assume_yes=True, + expected_name="flask", + expected_workspace="~/projects/", + expected_url="git+https://github.com/pallets/flask.git", + expected_written=True, + ), + UrlAddFixture( + test_id="url-no-declared-roots-falls-back-to-cwd", + repo_url="https://github.com/pallets/flask.git", + override_name=None, + workspace_override=None, + preexisting_yaml=None, + prompt_response=None, + assume_yes=True, + expected_name="flask", + expected_workspace="./", + expected_url="git+https://github.com/pallets/flask.git", + expected_written=True, + ), + UrlAddFixture( + test_id="url-scp-style-preserved-verbatim", + repo_url="git@github.com:vcs-python/libvcs.git", + override_name=None, + workspace_override="~/code/", + preexisting_yaml=TWO_ROOT_CONFIG, + prompt_response=None, + assume_yes=True, + expected_name="libvcs", + expected_workspace="~/code/", + expected_url="git@github.com:vcs-python/libvcs.git", + expected_written=True, + ), + UrlAddFixture( + test_id="url-explicit-git-plus-prefix-kept", + repo_url="git+https://github.com/pallets/flask.git", + override_name=None, + workspace_override="~/code/", + preexisting_yaml=TWO_ROOT_CONFIG, + prompt_response=None, + assume_yes=True, + expected_name="flask", + expected_workspace="~/code/", + expected_url="git+https://github.com/pallets/flask.git", + expected_written=True, + ), + UrlAddFixture( + test_id="url-name-override", + repo_url="https://github.com/pallets/flask.git", + override_name="flask-alias", + workspace_override="~/code/", + preexisting_yaml=TWO_ROOT_CONFIG, + prompt_response=None, + assume_yes=True, + expected_name="flask-alias", + expected_workspace="~/code/", + expected_url="git+https://github.com/pallets/flask.git", + expected_written=True, + ), +] + + +@pytest.mark.parametrize( + list(UrlAddFixture._fields), + URL_ADD_FIXTURES, + ids=[fixture.test_id for fixture in URL_ADD_FIXTURES], +) +def test_handle_add_command_url_mode( + test_id: str, + repo_url: str, + override_name: str | None, + workspace_override: str | None, + preexisting_yaml: str | None, + prompt_response: str | None, + assume_yes: bool, + expected_name: str, + expected_workspace: str, + expected_url: str, + expected_written: bool, + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """CLI URL mode declares a repository without requiring a checkout.""" + caplog.set_level(logging.INFO) + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + config_file = tmp_path / ".vcspull.yaml" + if preexisting_yaml is not None: + config_file.write_text(preexisting_yaml, encoding="utf-8") + + monkeypatch.setattr( + "builtins.input", + lambda _: prompt_response if prompt_response is not None else "y", + ) + + args = argparse.Namespace( + repo_path=repo_url, + url=None, + override_name=override_name, + config=str(config_file), + workspace_root_path=workspace_override, + dry_run=False, + assume_yes=assume_yes, + merge_duplicates=True, + ) + + handle_add_command(args) + + import yaml + + if not expected_written: + assert "Aborted import" in caplog.text + config_data = yaml.safe_load(config_file.read_text(encoding="utf-8")) + for section in config_data.values(): + assert expected_name not in section + return + + config_data = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert expected_workspace in config_data + assert config_data[expected_workspace][expected_name] == {"repo": expected_url} + + # Declaring must not touch the filesystem; sync does the cloning. + destination = ( + pathlib.Path(expected_workspace.replace("~", str(tmp_path), 1)) / expected_name + ) + assert not destination.exists() + + +def test_handle_add_command_url_rejects_redundant_url_flag( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Passing a URL argument and --url together is ambiguous, so it errors.""" + caplog.set_level(logging.INFO) + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + config_file = tmp_path / ".vcspull.yaml" + + args = argparse.Namespace( + repo_path="https://github.com/pallets/flask.git", + url="https://github.com/psf/requests.git", + override_name=None, + config=str(config_file), + workspace_root_path="~/code/", + dry_run=False, + assume_yes=True, + merge_duplicates=True, + ) + + handle_add_command(args) + + assert "Cannot combine a repository URL argument with --url" in caplog.text + assert not config_file.exists() + + +def test_handle_add_command_existing_directory_wins_over_url( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A directory that parses as a URL is still treated as a path. + + ``git@github.com:flask.git`` is both a valid scp-style remote and a legal + single-segment directory name, so it pins down the precedence rule. + """ + caplog.set_level(logging.INFO) + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + ambiguous = "git@github.com:flask.git" + repo_path = tmp_path / "workspace" / ambiguous + init_git_repo(repo_path, "https://github.com/pallets/flask.git") + + config_file = tmp_path / ".vcspull.yaml" + + monkeypatch.setattr("builtins.input", lambda _: "y") + + args = argparse.Namespace( + repo_path=str(repo_path), + url=None, + override_name=None, + config=str(config_file), + workspace_root_path=None, + dry_run=False, + assume_yes=True, + merge_duplicates=True, + ) + + handle_add_command(args) + + import yaml + + config_data = yaml.safe_load(config_file.read_text(encoding="utf-8")) + + # Path mode: the name comes from the directory and the URL from its remote. + assert config_data["~/workspace/"][ambiguous] == { + "repo": "git+https://github.com/pallets/flask.git", + } From ba91b9ff82a7c232b242e81fcdd9e0fa3741ea34 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Jul 2026 18:19:05 -0500 Subject: [PATCH 2/3] docs(cli[add]) Document adding a repository by URL why: The page framed `add` as pointing vcspull at a checkout on disk, which is now only half of what the command accepts, and the migration diff still routed the old `vcspull import ` through a path plus `--url`. what: - Reframe the intro around both forms and say the URL form records the entry without cloning - Add a section covering name derivation from the URL, the workspace roots offered when `--workspace` is absent, and the rule that an existing directory wins over a URL-shaped argument - Note that `--url` accompanies a path and is rejected alongside a URL - Restore the near-1:1 migration diff from the old import command --- docs/cli/add.md | 61 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/docs/cli/add.md b/docs/cli/add.md index 0c777745..2e7f7992 100644 --- a/docs/cli/add.md +++ b/docs/cli/add.md @@ -2,11 +2,12 @@ # vcspull add -The `vcspull add` command registers a repository in your -{ref}`configuration ` by pointing vcspull at a checkout on -disk. The command inspects the directory, -merges duplicate workspace roots by default, and prompts before writing unless -you pass `--yes`. +The `vcspull add` command registers a single repository in your +{ref}`configuration `. Point it at a checkout on disk and it +reads the details out of the directory; give it a repository URL and it records +the entry without cloning anything, leaving the working tree to +{ref}`vcspull sync `. Either way it merges duplicate workspace roots +by default and prompts before writing unless you pass `--yes`. ```{note} This command replaces the old `vcspull import ` from v1.36--v1.39. @@ -43,6 +44,37 @@ The parent directory (`~/study/python/` in this example) becomes the workspace root. vcspull shortens paths under `$HOME` to `~/...` in its log output so the preview stays readable. +## Declaring a repository you have not cloned + +Pass a repository URL instead of a path when you want the entry in your +configuration but do not have the code yet: + +```vcspull-console +$ vcspull add https://github.com/pallets/flask.git +Found new repository to import: + + flask (https://github.com/pallets/flask.git) + • workspace: ~/code/ + ↳ path: ~/code/flask + • workspace roots in ~/.vcspull.yaml: + 1) ~/code/ (default) + 2) ~/study/python/ +? Import this repository? [y/N/1-2]: y +✓ Successfully added 'flask' (git+https://github.com/pallets/flask.git) to ~/.vcspull.yaml under '~/code/'. +``` + +The repository name comes from the URL — `flask` here — unless you pass +`--name`. Nothing is fetched: the entry lands in your configuration and +{ref}`vcspull sync ` clones it the next time you run it. + +Because there is no parent directory to infer a workspace from, vcspull offers +the workspace roots your configuration already declares. Answering `y` accepts +the default, answering with a number picks a different root, and `--workspace` +names one outright and skips the list. When the configuration declares no roots +yet, the current directory becomes the workspace. + +A directory on disk always wins. If the argument names something that exists, +vcspull treats it as a path even when the same text would also parse as a URL. + ## Overriding detected information ### Choose a different name @@ -63,13 +95,18 @@ need to register a different remote or when the checkout does not have one yet: $ vcspull add ~/study/python/example --url https://github.com/org/example ``` +`--url` accompanies a path. When the argument is already a URL, pass it once and +leave `--url` off — supplying both is ambiguous, so vcspull stops rather than +guessing which one you meant. + URLs follow [pip's VCS format][pip vcs url]; vcspull inserts the `git+` prefix for HTTPS URLs so the resulting configuration matches {ref}`vcspull fmt ` output. ### Select a workspace explicitly -The workspace defaults to the checkout's parent directory. Pass +The workspace defaults to the checkout's parent directory, or — when you add by +URL — to the first workspace root your configuration declares. Pass `--workspace`/`--workspace-root` to store the repository under a different section: @@ -152,15 +189,17 @@ by `vcspull add`: ```diff - $ vcspull import flask https://github.com/pallets/flask.git -c ~/.vcspull.yaml -+ $ vcspull add ~/code/flask --url https://github.com/pallets/flask.git --file ~/.vcspull.yaml ++ $ vcspull add https://github.com/pallets/flask.git --file ~/.vcspull.yaml ``` Key differences: -- `vcspull add` derives the name from the filesystem unless you pass `--name`. -- The parent directory becomes the workspace automatically; use `--workspace` - to override. -- Use `--url` to record a remote when the checkout does not have one. +- `vcspull add` derives the name from the URL, or from the directory when you + add a checkout, unless you pass `--name`. +- The workspace comes from the checkout's parent directory, or from the + workspace roots your configuration declares when you add by URL; use + `--workspace` to override either. +- Use `--url` to record a remote when a checkout does not have one. ```{note} Starting with v1.55, `vcspull import` is a *different* command that bulk-imports From a5754e604dffe6dfd179f06f68ea19e995a8e0fd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Jul 2026 18:23:40 -0500 Subject: [PATCH 3/3] docs(CHANGES) Note adding a repository by URL why: Record the new `vcspull add ` surface for the v1.66 entry. --- CHANGES | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGES b/CHANGES index f0f687b2..a2fa0134 100644 --- a/CHANGES +++ b/CHANGES @@ -38,6 +38,19 @@ $ uvx --from 'vcspull' --prerelease allow vcspull _Notes on upcoming releases will be added here_ +### What's new + +#### Add a repository by URL + +{ref}`cli-add` now accepts a repository URL in place of a path, so you can +record a repository you have not cloned yet. The entry lands in your +configuration and {ref}`vcspull sync ` clones it on the next run. + +The name comes from the URL unless you pass `--name`. Because there is no parent +directory to infer a workspace from, vcspull offers the workspace roots your +configuration already declares, and `--workspace` names one outright. Adding a +checkout by path is unchanged — an existing directory still takes precedence. + ## vcspull v1.65.0 (2026-07-05) vcspull v1.65.0 sharpens the feedback you get when a sync cannot check out