From d55ee438ba0ea9fd79417e4106eacb1615d78fdd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 2 Nov 2025 12:49:10 -0600 Subject: [PATCH 1/6] cli/add(refactor[path-first]): Require repository path positional why: Explicit name+URL args allowed inconsistent workspace inference and bypassed confirmations, losing repos under './'. what: - drop the name positional, keeping path input as the single required argument - reuse optional --url/--name overrides while sharing the path-based workflow - ensure workspace defaults to the checkout parent unless --workspace overrides --- src/vcspull/_internal/config_reader.py | 6 ++-- src/vcspull/cli/add.py | 32 ++++++---------------- src/vcspull/cli/discover.py | 38 ++++++++++++-------------- tests/cli/test_add.py | 4 +-- tests/cli/test_discover.py | 2 +- tests/cli/test_fmt.py | 2 +- tests/conftest.py | 6 +++- tests/test_config.py | 2 +- tests/test_config_reader.py | 11 +++++--- 9 files changed, 46 insertions(+), 57 deletions(-) diff --git a/src/vcspull/_internal/config_reader.py b/src/vcspull/_internal/config_reader.py index 22583b6de..dc5339fe1 100644 --- a/src/vcspull/_internal/config_reader.py +++ b/src/vcspull/_internal/config_reader.py @@ -241,7 +241,7 @@ def _duplicate_tracking_construct_mapping( for key_node, value_node in node.value: construct = t.cast( - t.Callable[[yaml.nodes.Node], t.Any], + "t.Callable[[yaml.nodes.Node], t.Any]", loader.construct_object, ) key = construct(key_node) @@ -289,7 +289,7 @@ def _load_yaml_with_duplicates( try: data = loader.get_single_data() finally: - dispose = t.cast(t.Callable[[], None], loader.dispose) + dispose = t.cast("t.Callable[[], None]", loader.dispose) dispose() if data is None: @@ -301,7 +301,7 @@ def _load_yaml_with_duplicates( loaded = t.cast("dict[str, t.Any]", data) duplicate_sections = { - t.cast(str, key): values + t.cast("str", key): values for key, values in loader.top_level_key_values.items() if len(values) > 1 } diff --git a/src/vcspull/cli/add.py b/src/vcspull/cli/add.py index 8220907a4..de0ce2657 100644 --- a/src/vcspull/cli/add.py +++ b/src/vcspull/cli/add.py @@ -60,12 +60,6 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None: metavar="FILE", help="path to config file (default: ~/.vcspull.yaml or ./.vcspull.yaml)", ) - parser.add_argument( - "--path", - dest="path", - help="Local directory path where repo will be cloned " - "(determines workspace root if not specified with --workspace)", - ) parser.add_argument( "-w", "--workspace", @@ -171,23 +165,9 @@ def _normalize_detected_url(remote: str | None) -> tuple[str, str]: def handle_add_command(args: argparse.Namespace) -> None: """Entry point for the ``vcspull add`` CLI command.""" - explicit_url = getattr(args, "url", None) - - if explicit_url: - add_repo( - name=args.target, - url=explicit_url, - config_file_path_str=args.config, - path=args.path, - workspace_root_path=args.workspace_root_path, - dry_run=args.dry_run, - merge_duplicates=args.merge_duplicates, - ) - return - - repo_input = getattr(args, "target", None) + repo_input = getattr(args, "repo_path", None) if repo_input is None: - log.error("A repository path or name must be provided.") + log.error("A repository path must be provided.") return cwd = pathlib.Path.cwd() @@ -204,8 +184,12 @@ def handle_add_command(args: argparse.Namespace) -> None: override_name = getattr(args, "override_name", None) repo_name = override_name or repo_path.name - detected_remote = _detect_git_remote(repo_path) - display_url, config_url = _normalize_detected_url(detected_remote) + explicit_url = getattr(args, "url", None) + 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 = contract_user_home(repo_path) diff --git a/src/vcspull/cli/discover.py b/src/vcspull/cli/discover.py index 8d66afd39..6450aee47 100644 --- a/src/vcspull/cli/discover.py +++ b/src/vcspull/cli/discover.py @@ -262,26 +262,24 @@ def discover_repos( label, Style.RESET_ALL, ) - else: - if duplicate_root_occurrences: - duplicate_merge_details = [ - (label, len(values)) - for label, values in duplicate_root_occurrences.items() - ] - for label, occurrence_count in duplicate_merge_details: - log.warning( - "%s•%s Duplicate workspace root %s%s%s appears %s%d%s time%s; " - "skipping merge because --no-merge was provided.", - Fore.BLUE, - Style.RESET_ALL, - Fore.MAGENTA, - label, - Style.RESET_ALL, - Fore.YELLOW, - occurrence_count, - Style.RESET_ALL, - "" if occurrence_count == 1 else "s", - ) + elif duplicate_root_occurrences: + duplicate_merge_details = [ + (label, len(values)) for label, values in duplicate_root_occurrences.items() + ] + for label, occurrence_count in duplicate_merge_details: + log.warning( + "%s•%s Duplicate workspace root %s%s%s appears %s%d%s time%s; " + "skipping merge because --no-merge was provided.", + Fore.BLUE, + Style.RESET_ALL, + Fore.MAGENTA, + label, + Style.RESET_ALL, + Fore.YELLOW, + occurrence_count, + Style.RESET_ALL, + "" if occurrence_count == 1 else "s", + ) cwd = pathlib.Path.cwd() home = pathlib.Path.home() diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index cb1ae0bbb..44794ae3b 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -9,7 +9,6 @@ import typing as t import pytest -from syrupy.assertion import SnapshotAssertion from vcspull.cli.add import add_repo, handle_add_command from vcspull.util import contract_user_home @@ -18,6 +17,7 @@ import pathlib from _pytest.monkeypatch import MonkeyPatch + from syrupy.assertion import SnapshotAssertion class AddRepoFixture(t.NamedTuple): @@ -374,7 +374,7 @@ def test_add_repo_duplicate_merge_behavior( ~/study/python/: repo2: repo: git+https://example.com/repo2.git - """ + """, ), encoding="utf-8", ) diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index d10e3d119..e808ecf00 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -6,7 +6,6 @@ import typing as t import pytest -from syrupy.assertion import SnapshotAssertion from vcspull.cli.discover import discover_repos @@ -14,6 +13,7 @@ import pathlib from _pytest.monkeypatch import MonkeyPatch + from syrupy.assertion import SnapshotAssertion def init_git_repo(repo_path: pathlib.Path, remote_url: str) -> None: diff --git a/tests/cli/test_fmt.py b/tests/cli/test_fmt.py index 6d4e23a43..8fa4b7528 100644 --- a/tests/cli/test_fmt.py +++ b/tests/cli/test_fmt.py @@ -9,7 +9,6 @@ import pytest import yaml -from syrupy.assertion import SnapshotAssertion from vcspull.cli import cli from vcspull.cli.fmt import format_config, format_config_file, normalize_repo_config @@ -21,6 +20,7 @@ if t.TYPE_CHECKING: from _pytest.logging import LogCaptureFixture + from syrupy.assertion import SnapshotAssertion class WorkspaceRootFixture(t.NamedTuple): diff --git a/tests/conftest.py b/tests/conftest.py index 3aa4072a0..b2a389b8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,11 +2,15 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest -from syrupy.assertion import SnapshotAssertion from syrupy.extensions.json import JSONSnapshotExtension from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode +if TYPE_CHECKING: + from syrupy.assertion import SnapshotAssertion + class YamlSnapshotExtension(SingleFileSnapshotExtension): """Snapshot extension that persists plain-text YAML files.""" diff --git a/tests/test_config.py b/tests/test_config.py index f7fcf5f7d..55a4f000d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -158,7 +158,7 @@ def _write_duplicate_config(tmp_path: pathlib.Path) -> pathlib.Path: ~/workspace/: beta: repo: git+https://example.com/beta.git - """ + """, ), encoding="utf-8", ) diff --git a/tests/test_config_reader.py b/tests/test_config_reader.py index 0380d3787..a8769a95b 100644 --- a/tests/test_config_reader.py +++ b/tests/test_config_reader.py @@ -2,11 +2,14 @@ from __future__ import annotations -import pathlib import textwrap +from typing import TYPE_CHECKING from vcspull._internal.config_reader import DuplicateAwareConfigReader +if TYPE_CHECKING: + import pathlib + def _write(tmp_path: pathlib.Path, name: str, content: str) -> pathlib.Path: file_path = tmp_path / name @@ -24,7 +27,7 @@ def test_duplicate_aware_reader_records_yaml_duplicates(tmp_path: pathlib.Path) ~/study/python/: repo2: repo: git+https://example.com/repo2.git - """ + """, ) config_path = _write(tmp_path, "config.yaml", yaml_content) @@ -55,7 +58,7 @@ def test_duplicate_aware_reader_handles_yaml_without_duplicates( ~/code/: repo: repo: git+https://example.com/repo.git - """ + """, ) config_path = _write(tmp_path, "single.yaml", yaml_content) @@ -78,7 +81,7 @@ def test_duplicate_aware_reader_passes_through_json(tmp_path: pathlib.Path) -> N "repo": {"repo": "git+https://example.com/repo.git"} } } - """ + """, ) config_path = _write(tmp_path, "config.json", json_content) From 378878392047c59d724e87a1ce638b65c82202d8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 2 Nov 2025 12:49:18 -0600 Subject: [PATCH 2/6] tests/cli(add): Extend snapshots for path-only workflow why: Removing the name positional changes CLI prompts and URLs; tests need to validate --url overrides and avoid brittle line-number churn. what: - add fixtures covering explicit URL overrides while continuing merge toggles - scrub add.py line numbers from captured logs before snapshotting - refresh syrupy snapshots to assert the new log text --- tests/cli/__snapshots__/test_add.ambr | 89 ++++++++++++++++----------- tests/cli/test_add.py | 39 ++++++++++-- 2 files changed, 86 insertions(+), 42 deletions(-) diff --git a/tests/cli/__snapshots__/test_add.ambr b/tests/cli/__snapshots__/test_add.ambr index 6c2c10b59..e17c3c57b 100644 --- a/tests/cli/__snapshots__/test_add.ambr +++ b/tests/cli/__snapshots__/test_add.ambr @@ -2,8 +2,8 @@ # name: test_add_repo_duplicate_merge_behavior[merge-off] dict({ 'log': ''' - WARNING vcspull.cli.add:add.py:414 • Duplicate workspace root ~/study/python/ appears 2 times; skipping merge because --no-merge was provided. - INFO vcspull.cli.add:add.py:558 ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker.git) to under '~/study/python/'. + WARNING vcspull.cli.add:add.py: • Duplicate workspace root ~/study/python/ appears 2 times; skipping merge because --no-merge was provided. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker.git) to under '~/study/python/'. ''', 'test_id': 'merge-off', @@ -12,8 +12,8 @@ # name: test_add_repo_duplicate_merge_behavior[merge-on] dict({ 'log': ''' - INFO vcspull.cli.add:add.py:395 • Merged 2 duplicate entries for workspace root ~/study/python/ - INFO vcspull.cli.add:add.py:558 ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker.git) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: • Merged 2 duplicate entries for workspace root ~/study/python/ + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker.git) to under '~/study/python/'. ''', 'test_id': 'merge-on', @@ -22,27 +22,42 @@ # name: test_handle_add_command_path_mode[path-auto-confirm] dict({ 'log': ''' - INFO vcspull.cli.add:add.py:235 Found new repository to import: - INFO vcspull.cli.add:add.py:236 + pytest-docker (https://github.com/avast/pytest-docker) - INFO vcspull.cli.add:add.py:247 • workspace: ~/study/python/ - INFO vcspull.cli.add:add.py:255 ↳ path: ~/study/python/pytest-docker - INFO vcspull.cli.add:add.py:276 ? Import this repository? [y/N]: y (auto-confirm) - INFO vcspull.cli.add:add.py:374 Config file not found. A new one will be created. - INFO vcspull.cli.add:add.py:558 ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: Found new repository to import: + INFO vcspull.cli.add:add.py: + pytest-docker (https://github.com/avast/pytest-docker) + INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ + INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker + INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) + INFO vcspull.cli.add:add.py: Config file not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker) to under '~/study/python/'. ''', 'test_id': 'path-auto-confirm', }) # --- +# name: test_handle_add_command_path_mode[path-explicit-url] + dict({ + 'log': ''' + INFO vcspull.cli.add:add.py: Found new repository to import: + INFO vcspull.cli.add:add.py: + pytest-docker (https://github.com/manual/source) + INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ + INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker + INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) + INFO vcspull.cli.add:add.py: Config file not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/manual/source) to under '~/study/python/'. + + ''', + 'test_id': 'path-explicit-url', + }) +# --- # name: test_handle_add_command_path_mode[path-interactive-accept] dict({ 'log': ''' - INFO vcspull.cli.add:add.py:235 Found new repository to import: - INFO vcspull.cli.add:add.py:236 + project-alias (https://github.com/example/project) - INFO vcspull.cli.add:add.py:247 • workspace: ~/study/python/ - INFO vcspull.cli.add:add.py:255 ↳ path: ~/study/python/pytest-docker - INFO vcspull.cli.add:add.py:374 Config file not found. A new one will be created. - INFO vcspull.cli.add:add.py:558 ✓ Successfully added 'project-alias' (git+https://github.com/example/project) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: Found new repository to import: + INFO vcspull.cli.add:add.py: + project-alias (https://github.com/example/project) + INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ + INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker + INFO vcspull.cli.add:add.py: Config file not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'project-alias' (git+https://github.com/example/project) to under '~/study/python/'. ''', 'test_id': 'path-interactive-accept', @@ -51,11 +66,11 @@ # name: test_handle_add_command_path_mode[path-interactive-decline] dict({ 'log': ''' - INFO vcspull.cli.add:add.py:235 Found new repository to import: - INFO vcspull.cli.add:add.py:236 + pytest-docker (https://github.com/example/decline) - INFO vcspull.cli.add:add.py:247 • workspace: ~/study/python/ - INFO vcspull.cli.add:add.py:255 ↳ path: ~/study/python/pytest-docker - INFO vcspull.cli.add:add.py:290 Aborted import of 'pytest-docker' from + INFO vcspull.cli.add:add.py: Found new repository to import: + INFO vcspull.cli.add:add.py: + pytest-docker (https://github.com/example/decline) + INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ + INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker + INFO vcspull.cli.add:add.py: Aborted import of 'pytest-docker' from ''', 'test_id': 'path-interactive-decline', @@ -64,13 +79,13 @@ # name: test_handle_add_command_path_mode[path-no-merge] dict({ 'log': ''' - INFO vcspull.cli.add:add.py:235 Found new repository to import: - INFO vcspull.cli.add:add.py:236 + pytest-docker (https://github.com/example/no-merge) - INFO vcspull.cli.add:add.py:247 • workspace: ~/study/python/ - INFO vcspull.cli.add:add.py:255 ↳ path: ~/study/python/pytest-docker - INFO vcspull.cli.add:add.py:276 ? Import this repository? [y/N]: y (auto-confirm) - WARNING vcspull.cli.add:add.py:414 • Duplicate workspace root ~/study/python/ appears 2 times; skipping merge because --no-merge was provided. - INFO vcspull.cli.add:add.py:558 ✓ Successfully added 'pytest-docker' (git+https://github.com/example/no-merge) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: Found new repository to import: + INFO vcspull.cli.add:add.py: + pytest-docker (https://github.com/example/no-merge) + INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ + INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker + INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) + WARNING vcspull.cli.add:add.py: • Duplicate workspace root ~/study/python/ appears 2 times; skipping merge because --no-merge was provided. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/example/no-merge) to under '~/study/python/'. ''', 'test_id': 'path-no-merge', @@ -79,14 +94,14 @@ # name: test_handle_add_command_path_mode[path-no-remote] dict({ 'log': ''' - WARNING vcspull.cli.add:add.py:213 Unable to determine git remote for ; using local path in config. - INFO vcspull.cli.add:add.py:235 Found new repository to import: - INFO vcspull.cli.add:add.py:236 + pytest-docker (~/study/python/pytest-docker) - INFO vcspull.cli.add:add.py:247 • workspace: ~/study/python/ - INFO vcspull.cli.add:add.py:255 ↳ path: ~/study/python/pytest-docker - INFO vcspull.cli.add:add.py:276 ? Import this repository? [y/N]: y (auto-confirm) - INFO vcspull.cli.add:add.py:374 Config file not found. A new one will be created. - INFO vcspull.cli.add:add.py:558 ✓ Successfully added 'pytest-docker' () to under '~/study/python/'. + WARNING vcspull.cli.add:add.py: Unable to determine git remote for ; using local path in config. + INFO vcspull.cli.add:add.py: Found new repository to import: + INFO vcspull.cli.add:add.py: + pytest-docker (~/study/python/pytest-docker) + INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ + INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker + INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) + INFO vcspull.cli.add:add.py: Config file not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' () to under '~/study/python/'. ''', 'test_id': 'path-no-remote', diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index 44794ae3b..3932a9f3b 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -4,6 +4,7 @@ import argparse import logging +import re import subprocess import textwrap import typing as t @@ -400,6 +401,7 @@ def test_add_repo_duplicate_merge_behavior( assert expected_warning in caplog.text normalized_log = caplog.text.replace(str(config_file), "") + normalized_log = re.sub(r"add\.py:\d+", "add.py:", normalized_log) snapshot.assert_match({"test_id": test_id, "log": normalized_log}) @@ -408,11 +410,12 @@ class PathAddFixture(t.NamedTuple): test_id: str remote_url: str | None + explicit_url: str | None assume_yes: bool prompt_response: str | None dry_run: bool expected_written: bool - expected_url_kind: str # "remote" or "path" + expected_url_kind: str # "remote", "path", or "explicit" override_name: str | None expected_warning: str | None merge_duplicates: bool @@ -423,6 +426,7 @@ class PathAddFixture(t.NamedTuple): PathAddFixture( test_id="path-auto-confirm", remote_url="https://github.com/avast/pytest-docker", + explicit_url=None, assume_yes=True, prompt_response=None, dry_run=False, @@ -436,6 +440,7 @@ class PathAddFixture(t.NamedTuple): PathAddFixture( test_id="path-interactive-accept", remote_url="https://github.com/example/project", + explicit_url=None, assume_yes=False, prompt_response="y", dry_run=False, @@ -449,6 +454,7 @@ class PathAddFixture(t.NamedTuple): PathAddFixture( test_id="path-interactive-decline", remote_url="https://github.com/example/decline", + explicit_url=None, assume_yes=False, prompt_response="n", dry_run=False, @@ -462,6 +468,7 @@ class PathAddFixture(t.NamedTuple): PathAddFixture( test_id="path-no-remote", remote_url=None, + explicit_url=None, assume_yes=True, prompt_response=None, dry_run=False, @@ -475,6 +482,7 @@ class PathAddFixture(t.NamedTuple): PathAddFixture( test_id="path-no-merge", remote_url="https://github.com/example/no-merge", + explicit_url=None, assume_yes=True, prompt_response=None, dry_run=False, @@ -492,6 +500,20 @@ class PathAddFixture(t.NamedTuple): repo: git+https://example.com/repo2.git """, ), + PathAddFixture( + test_id="path-explicit-url", + remote_url=None, + explicit_url="https://github.com/manual/source", + assume_yes=True, + prompt_response=None, + dry_run=False, + expected_written=True, + expected_url_kind="explicit", + override_name=None, + expected_warning=None, + merge_duplicates=True, + preexisting_yaml=None, + ), ] @@ -503,6 +525,7 @@ class PathAddFixture(t.NamedTuple): def test_handle_add_command_path_mode( test_id: str, remote_url: str | None, + explicit_url: str | None, assume_yes: bool, prompt_response: str | None, dry_run: bool, @@ -535,11 +558,10 @@ def test_handle_add_command_path_mode( monkeypatch.setattr("builtins.input", lambda _: expected_input) args = argparse.Namespace( - target=str(repo_path), - url=None, + repo_path=str(repo_path), + url=explicit_url, override_name=override_name, config=str(config_file), - path=None, workspace_root_path=None, dry_run=dry_run, assume_yes=assume_yes, @@ -556,6 +578,7 @@ def test_handle_add_command_path_mode( normalized_log = log_output.replace(str(config_file), "") normalized_log = normalized_log.replace(str(repo_path), "") + normalized_log = re.sub(r"add\.py:\d+", "add.py:", normalized_log) snapshot.assert_match({"test_id": test_id, "log": normalized_log}) if dry_run: @@ -582,7 +605,13 @@ def test_handle_add_command_path_mode( repo_entry = config_data[workspace][repo_name] expected_url: str - if expected_url_kind == "remote" and remote_url is not None: + if expected_url_kind == "explicit" and explicit_url is not None: + expected_url = ( + explicit_url + if explicit_url.startswith("git+") + else f"git+{explicit_url}" + ) + elif expected_url_kind == "remote" and remote_url is not None: cleaned_remote = remote_url.strip() expected_url = ( cleaned_remote From 33468e3c09d1200c09d183794238b4b35e7fa399 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 2 Nov 2025 14:55:17 -0600 Subject: [PATCH 3/6] cli/add(refactor): Make repository path the sole positional why: CLI accepted name+URL combinations that bypassed the path-first flow, leaving repos under './' and skipping confirmations. what: - replace the positional name/url pair with a single repo_path argument plus optional --url/--name overrides - default workspace detection to the directory parent and contract saved config paths with contract_user_home --- src/vcspull/cli/add.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/vcspull/cli/add.py b/src/vcspull/cli/add.py index de0ce2657..b0459bf84 100644 --- a/src/vcspull/cli/add.py +++ b/src/vcspull/cli/add.py @@ -37,22 +37,22 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None: The parser to configure """ parser.add_argument( - "target", + "repo_path", help=( - "Repository name (when providing a URL) or filesystem path to an " - "existing project" + "Filesystem path to an existing project. The parent directory " + "becomes the workspace unless overridden with --workspace." ), ) - parser.add_argument( - "url", - nargs="?", - help="Repository URL when explicitly specifying the name", - ) parser.add_argument( "--name", dest="override_name", help="Override detected repository name when importing from a path", ) + parser.add_argument( + "--url", + dest="url", + help="Repository URL to record (overrides detected remotes)", + ) parser.add_argument( "-f", "--file", @@ -67,8 +67,8 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None: dest="workspace_root_path", metavar="DIR", help=( - "Workspace root directory in config (e.g., '~/projects/'). " - "If not specified, will be inferred from --path or use current directory." + "Workspace root directory in config (e.g., '~/projects/'). Defaults " + "to the parent directory of the repository path." ), ) parser.add_argument( @@ -322,7 +322,7 @@ def add_repo( config_file_path = pathlib.Path.cwd() / ".vcspull.yaml" log.info( "No config specified and no default found, will create at %s", - config_file_path, + contract_user_home(config_file_path), ) elif len(home_configs) > 1: log.error( @@ -335,6 +335,8 @@ def add_repo( # Load existing config raw_config: dict[str, t.Any] duplicate_root_occurrences: dict[str, list[t.Any]] + display_config_path = contract_user_home(config_file_path) + if config_file_path.exists() and config_file_path.is_file(): try: ( @@ -357,7 +359,7 @@ def add_repo( duplicate_root_occurrences = {} log.info( "Config file %s not found. A new one will be created.", - config_file_path, + display_config_path, ) duplicate_merge_conflicts: list[str] = [] @@ -494,7 +496,7 @@ def add_repo( Fore.YELLOW, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, ) else: @@ -505,7 +507,7 @@ def add_repo( Fore.GREEN, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, ) except Exception: @@ -530,7 +532,7 @@ def add_repo( url, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, Fore.MAGENTA, workspace_label, @@ -550,7 +552,7 @@ def add_repo( url, Style.RESET_ALL, Fore.BLUE, - config_file_path, + display_config_path, Style.RESET_ALL, Fore.MAGENTA, workspace_label, From b04e65a6562f30db76353d1b65d93be87f7ffd9d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 2 Nov 2025 15:44:44 -0600 Subject: [PATCH 4/6] tests/cli(add): Cover path-only workflow edge cases why: New CLI behavior needs regression coverage for relative paths, workspace overrides, tilde log output, and legacy positional rejection. what: - extend path fixtures to toggle relative inputs, workspace overrides, and dry-run tilde assertions - normalize logs for snapshots while preserving contracted config paths when required - add unit checks for dry-run logging and parser rejection of extra positional args --- tests/cli/__snapshots__/test_add.ambr | 67 ++++++++++-- tests/cli/test_add.py | 151 +++++++++++++++++++++++++- 2 files changed, 201 insertions(+), 17 deletions(-) diff --git a/tests/cli/__snapshots__/test_add.ambr b/tests/cli/__snapshots__/test_add.ambr index e17c3c57b..e642f1f68 100644 --- a/tests/cli/__snapshots__/test_add.ambr +++ b/tests/cli/__snapshots__/test_add.ambr @@ -3,7 +3,7 @@ dict({ 'log': ''' WARNING vcspull.cli.add:add.py: • Duplicate workspace root ~/study/python/ appears 2 times; skipping merge because --no-merge was provided. - INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker.git) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker.git) to ~/.vcspull.yaml under '~/study/python/'. ''', 'test_id': 'merge-off', @@ -13,7 +13,7 @@ dict({ 'log': ''' INFO vcspull.cli.add:add.py: • Merged 2 duplicate entries for workspace root ~/study/python/ - INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker.git) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker.git) to ~/.vcspull.yaml under '~/study/python/'. ''', 'test_id': 'merge-on', @@ -27,13 +27,28 @@ INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) - INFO vcspull.cli.add:add.py: Config file not found. A new one will be created. - INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: Config file ~/.vcspull.yaml not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker) to ~/.vcspull.yaml under '~/study/python/'. ''', 'test_id': 'path-auto-confirm', }) # --- +# name: test_handle_add_command_path_mode[path-dry-run-shows-tilde-config] + dict({ + 'log': ''' + INFO vcspull.cli.add:add.py: Found new repository to import: + INFO vcspull.cli.add:add.py: + pytest-docker (https://github.com/example/tilde) + INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ + INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker + INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: skipped (dry-run) + INFO vcspull.cli.add:add.py: Config file ~/.vcspull.yaml not found. A new one will be created. + INFO vcspull.cli.add:add.py: → Would add 'pytest-docker' (git+https://github.com/example/tilde) to ~/.vcspull.yaml under '~/study/python/'. + + ''', + 'test_id': 'path-dry-run-shows-tilde-config', + }) +# --- # name: test_handle_add_command_path_mode[path-explicit-url] dict({ 'log': ''' @@ -42,8 +57,8 @@ INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) - INFO vcspull.cli.add:add.py: Config file not found. A new one will be created. - INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/manual/source) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: Config file ~/.vcspull.yaml not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/manual/source) to ~/.vcspull.yaml under '~/study/python/'. ''', 'test_id': 'path-explicit-url', @@ -56,8 +71,8 @@ INFO vcspull.cli.add:add.py: + project-alias (https://github.com/example/project) INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker - INFO vcspull.cli.add:add.py: Config file not found. A new one will be created. - INFO vcspull.cli.add:add.py: ✓ Successfully added 'project-alias' (git+https://github.com/example/project) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: Config file ~/.vcspull.yaml not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'project-alias' (git+https://github.com/example/project) to ~/.vcspull.yaml under '~/study/python/'. ''', 'test_id': 'path-interactive-accept', @@ -85,7 +100,7 @@ INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) WARNING vcspull.cli.add:add.py: • Duplicate workspace root ~/study/python/ appears 2 times; skipping merge because --no-merge was provided. - INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/example/no-merge) to under '~/study/python/'. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/example/no-merge) to ~/.vcspull.yaml under '~/study/python/'. ''', 'test_id': 'path-no-merge', @@ -100,10 +115,40 @@ INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) - INFO vcspull.cli.add:add.py: Config file not found. A new one will be created. - INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' () to under '~/study/python/'. + INFO vcspull.cli.add:add.py: Config file ~/.vcspull.yaml not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' () to ~/.vcspull.yaml under '~/study/python/'. ''', 'test_id': 'path-no-remote', }) # --- +# name: test_handle_add_command_path_mode[path-relative-derives-workspace] + dict({ + 'log': ''' + INFO vcspull.cli.add:add.py: Found new repository to import: + INFO vcspull.cli.add:add.py: + pytest-docker (https://github.com/example/rel) + INFO vcspull.cli.add:add.py: • workspace: ~/study/python/ + INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker + INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) + INFO vcspull.cli.add:add.py: Config file ~/.vcspull.yaml not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/example/rel) to ~/.vcspull.yaml under '~/study/python/'. + + ''', + 'test_id': 'path-relative-derives-workspace', + }) +# --- +# name: test_handle_add_command_path_mode[path-workspace-override] + dict({ + 'log': ''' + INFO vcspull.cli.add:add.py: Found new repository to import: + INFO vcspull.cli.add:add.py: + pytest-docker (https://github.com/example/workspace) + INFO vcspull.cli.add:add.py: • workspace: ~/custom/ + INFO vcspull.cli.add:add.py: ↳ path: ~/study/python/pytest-docker + INFO vcspull.cli.add:add.py: ? Import this repository? [y/N]: y (auto-confirm) + INFO vcspull.cli.add:add.py: Config file ~/.vcspull.yaml not found. A new one will be created. + INFO vcspull.cli.add:add.py: ✓ Successfully added 'pytest-docker' (git+https://github.com/example/workspace) to ~/.vcspull.yaml under '~/custom/'. + + ''', + 'test_id': 'path-workspace-override', + }) +# --- diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index 3932a9f3b..c488be9d7 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -4,6 +4,7 @@ import argparse import logging +import os import re import subprocess import textwrap @@ -11,7 +12,7 @@ import pytest -from vcspull.cli.add import add_repo, handle_add_command +from vcspull.cli.add import add_repo, create_add_subparser, handle_add_command from vcspull.util import contract_user_home if t.TYPE_CHECKING: @@ -420,6 +421,10 @@ class PathAddFixture(t.NamedTuple): expected_warning: str | None merge_duplicates: bool preexisting_yaml: str | None + use_relative_repo_path: bool + workspace_override: str | None + expected_workspace_label: str + preserve_config_path_in_log: bool PATH_ADD_FIXTURES: list[PathAddFixture] = [ @@ -436,6 +441,10 @@ class PathAddFixture(t.NamedTuple): expected_warning=None, merge_duplicates=True, preexisting_yaml=None, + use_relative_repo_path=False, + workspace_override=None, + expected_workspace_label="~/study/python/", + preserve_config_path_in_log=False, ), PathAddFixture( test_id="path-interactive-accept", @@ -450,6 +459,10 @@ class PathAddFixture(t.NamedTuple): expected_warning=None, merge_duplicates=True, preexisting_yaml=None, + use_relative_repo_path=False, + workspace_override=None, + expected_workspace_label="~/study/python/", + preserve_config_path_in_log=False, ), PathAddFixture( test_id="path-interactive-decline", @@ -464,6 +477,10 @@ class PathAddFixture(t.NamedTuple): expected_warning=None, merge_duplicates=True, preexisting_yaml=None, + use_relative_repo_path=False, + workspace_override=None, + expected_workspace_label="~/study/python/", + preserve_config_path_in_log=False, ), PathAddFixture( test_id="path-no-remote", @@ -478,6 +495,10 @@ class PathAddFixture(t.NamedTuple): expected_warning="Unable to determine git remote", merge_duplicates=True, preexisting_yaml=None, + use_relative_repo_path=False, + workspace_override=None, + expected_workspace_label="~/study/python/", + preserve_config_path_in_log=False, ), PathAddFixture( test_id="path-no-merge", @@ -499,6 +520,10 @@ class PathAddFixture(t.NamedTuple): repo2: repo: git+https://example.com/repo2.git """, + use_relative_repo_path=False, + workspace_override=None, + expected_workspace_label="~/study/python/", + preserve_config_path_in_log=False, ), PathAddFixture( test_id="path-explicit-url", @@ -513,6 +538,64 @@ class PathAddFixture(t.NamedTuple): expected_warning=None, merge_duplicates=True, preexisting_yaml=None, + use_relative_repo_path=False, + workspace_override=None, + expected_workspace_label="~/study/python/", + preserve_config_path_in_log=False, + ), + PathAddFixture( + test_id="path-relative-derives-workspace", + remote_url="https://github.com/example/rel", + explicit_url=None, + assume_yes=True, + prompt_response=None, + dry_run=False, + expected_written=True, + expected_url_kind="remote", + override_name=None, + expected_warning=None, + merge_duplicates=True, + preexisting_yaml=None, + use_relative_repo_path=True, + workspace_override=None, + expected_workspace_label="~/study/python/", + preserve_config_path_in_log=False, + ), + PathAddFixture( + test_id="path-workspace-override", + remote_url="https://github.com/example/workspace", + explicit_url=None, + assume_yes=True, + prompt_response=None, + dry_run=False, + expected_written=True, + expected_url_kind="remote", + override_name=None, + expected_warning=None, + merge_duplicates=True, + preexisting_yaml=None, + use_relative_repo_path=False, + workspace_override="~/custom/", + expected_workspace_label="~/custom/", + preserve_config_path_in_log=False, + ), + PathAddFixture( + test_id="path-dry-run-shows-tilde-config", + remote_url="https://github.com/example/tilde", + explicit_url=None, + assume_yes=False, + prompt_response=None, + dry_run=True, + expected_written=False, + expected_url_kind="remote", + override_name=None, + expected_warning=None, + merge_duplicates=True, + preexisting_yaml=None, + use_relative_repo_path=False, + workspace_override=None, + expected_workspace_label="~/study/python/", + preserve_config_path_in_log=True, ), ] @@ -535,6 +618,10 @@ def test_handle_add_command_path_mode( expected_warning: str | None, merge_duplicates: bool, preexisting_yaml: str | None, + use_relative_repo_path: bool, + workspace_override: str | None, + expected_workspace_label: str, + preserve_config_path_in_log: bool, tmp_path: pathlib.Path, monkeypatch: MonkeyPatch, caplog: t.Any, @@ -557,12 +644,18 @@ def test_handle_add_command_path_mode( expected_input = prompt_response if prompt_response is not None else "y" monkeypatch.setattr("builtins.input", lambda _: expected_input) + repo_arg: str + if use_relative_repo_path: + repo_arg = os.fspath(repo_path.relative_to(tmp_path)) + else: + repo_arg = str(repo_path) + args = argparse.Namespace( - repo_path=str(repo_path), + repo_path=repo_arg, url=explicit_url, override_name=override_name, config=str(config_file), - workspace_root_path=None, + workspace_root_path=workspace_override, dry_run=dry_run, assume_yes=assume_yes, merge_duplicates=merge_duplicates, @@ -579,7 +672,16 @@ def test_handle_add_command_path_mode( normalized_log = log_output.replace(str(config_file), "") normalized_log = normalized_log.replace(str(repo_path), "") normalized_log = re.sub(r"add\.py:\d+", "add.py:", normalized_log) - snapshot.assert_match({"test_id": test_id, "log": normalized_log}) + if preserve_config_path_in_log: + assert contract_user_home(config_file) in log_output + snapshot.assert_match( + { + "test_id": test_id, + "log": normalized_log.replace("", "~/.vcspull.yaml"), + } + ) + else: + snapshot.assert_match({"test_id": test_id, "log": normalized_log}) if dry_run: assert "skipped (dry-run)" in log_output @@ -599,7 +701,7 @@ def test_handle_add_command_path_mode( with config_file.open(encoding="utf-8") as fh: config_data = yaml.safe_load(fh) - workspace = "~/study/python/" + workspace = expected_workspace_label assert workspace in config_data assert repo_name in config_data[workspace] @@ -632,4 +734,41 @@ def test_handle_add_command_path_mode( workspace = config_data.get("~/study/python/") if workspace is not None: assert repo_name not in workspace - assert "Aborted import" in log_output + if not dry_run: + assert "Aborted import" in log_output + + +def test_add_repo_dry_run_contracts_config_path( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, + caplog: t.Any, +) -> None: + """Dry-run logging shows tilde-shortened config file paths.""" + caplog.set_level(logging.INFO) + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + config_path = tmp_path / ".vcspull.yaml" + + add_repo( + name="tilde-repo", + url="git+https://example.com/tilde-repo.git", + config_file_path_str=str(config_path), + path=str(tmp_path / "projects/tilde-repo"), + workspace_root_path=None, + dry_run=True, + ) + + assert "~/.vcspull.yaml" in caplog.text + + +def test_add_parser_rejects_extra_positional() -> None: + """Passing both name and URL should raise a parse error in the new parser.""" + parser = argparse.ArgumentParser(prog="vcspull") + subparsers = parser.add_subparsers(dest="command", required=True) + add_parser = subparsers.add_parser("add") + create_add_subparser(add_parser) + + with pytest.raises(SystemExit): + parser.parse_args(["add", "name", "https://example.com/repo.git"]) From dacd312ba765264fcc960d77ccff0047f8514dec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 2 Nov 2025 16:26:14 -0600 Subject: [PATCH 5/6] tests/cli(discover): Decouple snapshots from line numbers why: Ruff refactors change discover.py line numbers, causing snapshot churn and breaking pytest whenever logging shifts. what: - normalize captured logs to replace discover.py: with a stable placeholder before snapshot assertions - refresh the affected Syrupy snapshot to match the normalized format --- tests/cli/__snapshots__/test_discover.ambr | 10 +++++----- tests/cli/test_discover.py | 6 ++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/cli/__snapshots__/test_discover.ambr b/tests/cli/__snapshots__/test_discover.ambr index 15368c7ae..ebf049842 100644 --- a/tests/cli/__snapshots__/test_discover.ambr +++ b/tests/cli/__snapshots__/test_discover.ambr @@ -2,12 +2,12 @@ # name: test_discover_repos[discover-no-merge] dict({ 'log': ''' - WARNING vcspull.cli.discover:discover.py:272 • Duplicate workspace root ~/code/ appears 2 times; skipping merge because --no-merge was provided. - INFO vcspull.cli.discover:discover.py:469 + WARNING vcspull.cli.discover:discover.py: • Duplicate workspace root ~/code/ appears 2 times; skipping merge because --no-merge was provided. + INFO vcspull.cli.discover:discover.py: Found 1 new repository to import: - INFO vcspull.cli.discover:discover.py:478 + repo3 (git+https://github.com/user/repo3.git) - INFO vcspull.cli.discover:discover.py:527 + Importing 'repo3' (git+https://github.com/user/repo3.git) under '~/code/'. - INFO vcspull.cli.discover:discover.py:546 ✓ Successfully updated . + INFO vcspull.cli.discover:discover.py: + repo3 (git+https://github.com/user/repo3.git) + INFO vcspull.cli.discover:discover.py: + Importing 'repo3' (git+https://github.com/user/repo3.git) under '~/code/'. + INFO vcspull.cli.discover:discover.py: ✓ Successfully updated . ''', 'test_id': 'discover-no-merge', diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index e808ecf00..391e3231d 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import subprocess import typing as t @@ -361,6 +362,11 @@ def test_discover_repos( if preexisting_yaml is not None or not merge_duplicates: normalized_log = caplog.text.replace(str(target_config_file), "") + normalized_log = re.sub( + r"discover\.py:\d+", + "discover.py:", + normalized_log, + ) snapshot.assert_match({"test_id": test_id, "log": normalized_log}) if dry_run: From e2d16bf385f8461d020ac687e0af504f61528368 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 2 Nov 2025 12:49:28 -0600 Subject: [PATCH 6/6] docs(CHANGES): Record path-first vcspull add behavior why: The name+URL syntax lived in released notes already; we need to log the new positional path requirement in the unreleased section and refresh user docs. what: - describe the path-first workflow under the unreleased changelog heading - rewrite the add command guide and README bullets around path detection, --url, and --name overrides Also, Document vcspull add path-first work why: Release notes should reflect the path-only CLI change and the log normalization fixes shipped in PR #481. what: - attribute the path-first improvements to #481 and note the tilde-config logging tweak - call out the snapshot stabilization work under a Development heading --- CHANGES | 20 +++++- README.md | 17 +++-- docs/cli/add.md | 167 ++++++++++++++++++------------------------------ 3 files changed, 89 insertions(+), 115 deletions(-) diff --git a/CHANGES b/CHANGES index 37694c7bf..ff8613d11 100644 --- a/CHANGES +++ b/CHANGES @@ -29,9 +29,25 @@ $ uvx --from 'vcspull' --prerelease allow vcspull ## vcspull v1.44.x (unreleased) - +### Improvements + +#### `vcspull add` streamlines path-first imports (#481) + +- The CLI now requires a repository path as its positional argument, inferring + the name and `origin` remote automatically. Supply `--url` to record an + alternative remote and `--name` when you need a different label. +- Workspace roots default to the checkout's parent directory; use + `--workspace/--workspace-root` to override the destination while keeping the + path-first flow intact. +- CLI output contracts configuration paths to `~/.vcspull.yaml`, keeping + dry-run previews concise when configs live under the home directory. + +### Development + +#### Snapshot stability for CLI logs (#481) -_Upcoming changes will be written here._ +- Discover command snapshots replace volatile line numbers with placeholders so + future refactors and lint rewrites do not break the test suite. ## vcspull v1.43.0 (2025-11-02) diff --git a/README.md b/README.md index d016771ec..92cc75249 100644 --- a/README.md +++ b/README.md @@ -93,19 +93,18 @@ machines. Subsequent syncs of initialized repos will fetch the latest commits. ### Add repositories from the CLI -Register a single repository without touching YAML manually: +Register a single repository by pointing at the checkout: ```console -$ vcspull add my-lib https://github.com/example/my-lib.git --path ~/code/my-lib +$ vcspull add ~/projects/libs/my-lib ``` -- Omit `--path` to default the entry under `./`. -- Use `-w/--workspace` when you want to force a specific workspace root, e.g. - `-w ~/projects/libs`. -- Pass `-f/--file` to add to an alternate YAML file. -- Use `--dry-run` to preview changes before writing. -- Point at an existing checkout (`vcspull add ~/projects/example`) to infer the - name and remote; add `--yes` to skip the confirmation prompt. +- vcspull infers the name from the directory and detects the `origin` remote. + Pass `--url` when you need to record a different remote. +- Override the derived name with `--name` and the workspace root with + `-w/--workspace`. +- `--dry-run` previews the update, while `--yes` skips the confirmation prompt. +- `-f/--file` selects an alternate configuration file. - Append `--no-merge` if you prefer to review duplicate workspace roots yourself instead of having vcspull merge them automatically. - Follow with `vcspull sync my-lib` to clone or update the working tree after registration. diff --git a/docs/cli/add.md b/docs/cli/add.md index aab6e7623..e6211c249 100644 --- a/docs/cli/add.md +++ b/docs/cli/add.md @@ -2,9 +2,10 @@ # vcspull add -The `vcspull add` command adds a single repository to your vcspull configuration. -Provide a repository name and URL, and vcspull will append it to your config file -with the appropriate workspace root. +The `vcspull add` command registers a repository in your 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`. ```{note} This command replaces the manual import functionality from `vcspull import`. @@ -24,154 +25,112 @@ For bulk scanning of existing repositories, see {ref}`cli-discover`. ## Basic usage -Add a repository by name and URL: +Point to an existing checkout to add it under its parent workspace: ```console -$ vcspull add flask https://github.com/pallets/flask.git -Successfully added 'flask' to ./.vcspull.yaml under './' +$ vcspull add ~/study/python/pytest-docker +Found new repository to import: + + pytest-docker (https://github.com/avast/pytest-docker) + • workspace: ~/study/python/ + ↳ path: ~/study/python/pytest-docker +? Import this repository? [y/N]: y +Successfully added 'pytest-docker' (git+https://github.com/avast/pytest-docker) to ~/.vcspull.yaml under '~/study/python/'. ``` -By default, the repository is added to the current directory's workspace root (`./`). +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. -## Specifying workspace root +## Overriding detected information -Use `-w/--workspace` or `--workspace-root` to control where the repository will be checked out: +### Choose a different name -```console -$ vcspull add flask https://github.com/pallets/flask.git -w ~/code/ -Successfully added 'flask' to ~/.vcspull.yaml under '~/code/' -``` - -All three flag names work identically: +Override the derived repository name with `--name` when the directory name +isn't the label you want stored in the configuration: ```console -$ vcspull add django https://github.com/django/django.git --workspace ~/code/ -$ vcspull add requests https://github.com/psf/requests.git --workspace-root ~/code/ +$ vcspull add ~/study/python/pytest-docker --name docker-pytest ``` -## Custom repository path +### Override the remote URL -Override the inferred path with `--path` when the repository already exists on disk: +vcspull reads the Git `origin` remote automatically. Supply `--url` when you +need to register a different remote or when the checkout does not have one yet: ```console -$ vcspull add my-lib https://github.com/example/my-lib.git \ - --path ~/code/libraries/my-lib +$ vcspull add ~/study/python/example --url https://github.com/org/example ``` -The `--path` flag is useful when: -- Migrating existing local repositories -- Using non-standard directory layouts -- The repository name doesn't match the desired directory name - -You can also point `vcspull add` at an existing checkout. Supplying a path such -as `vcspull add ~/projects/example` infers the repository name, inspects its -`origin` remote, and prompts before writing. Add `--yes` when you need to skip -the confirmation in scripts. - -## Choosing configuration files - -By default, vcspull looks for the first YAML configuration file in: -1. Current directory (`.vcspull.yaml`) -2. Home directory (`~/.vcspull.yaml`) -3. XDG config directory (`~/.config/vcspull/`) +URLs follow [pip's VCS format][pip vcs url]; vcspull inserts the `git+` prefix +for HTTPS URLs so the resulting configuration matches `vcspull fmt` output. -If no config exists, a new `.vcspull.yaml` is created in the current directory. +### Select a workspace explicitly -Specify a custom config file with `-f/--file`: +The workspace defaults to the checkout's parent directory. Pass +`--workspace`/`--workspace-root` to store the repository under a different +section: ```console -$ vcspull add vcspull https://github.com/vcs-python/vcspull.git \ - -f ~/projects/.vcspull.yaml +$ vcspull add ~/scratch/tmp-project --workspace ~/projects/python/ ``` -## Dry run mode - -Preview changes without modifying your configuration with `--dry-run` or `-n`: - -```console -$ vcspull add flask https://github.com/pallets/flask.git -w ~/code/ --dry-run -Would add 'flask' (https://github.com/pallets/flask.git) to ~/.vcspull.yaml under '~/code/' -``` - -This is useful for: -- Verifying the workspace root is correct -- Checking which config file will be modified -- Testing path inference - -## URL formats - -Repositories use [pip VCS URL][pip vcs url] format with a scheme prefix: +## Confirmation and dry runs -- Git: `git+https://github.com/user/repo.git` -- Mercurial: `hg+https://bitbucket.org/user/repo` -- Subversion: `svn+http://svn.example.org/repo/trunk` - -The URL scheme determines the VCS type. For Git, the `git+` prefix is required. - -## Examples - -Add to default location: - -```console -$ vcspull add myproject https://github.com/myuser/myproject.git -``` - -Add to specific workspace: +`vcspull add` asks for confirmation before writing. Use `--yes` to skip the +prompt in automation, or `--dry-run`/`-n` to preview the changes without +modifying any files: ```console -$ vcspull add django-blog https://github.com/example/django-blog.git \ - -w ~/code/django/ +$ vcspull add ~/study/python/pytest-docker --dry-run ``` -Add with custom path: +Dry runs still show duplicate merge diagnostics so you can see what would +change. -```console -$ vcspull add dotfiles https://github.com/myuser/dotfiles.git \ - --path ~/.dotfiles -``` +## Choosing configuration files -Preview before adding: +vcspull searches for configuration files in this order: -```console -$ vcspull add flask https://github.com/pallets/flask.git \ - -w ~/code/ --dry-run -``` +1. `./.vcspull.yaml` +2. `~/.vcspull.yaml` +3. `~/.config/vcspull/*.yaml` -Add to specific config file: +Specify a file explicitly with `-f/--file`: ```console -$ vcspull add tooling https://github.com/company/tooling.git \ - -f ~/company/.vcspull.yaml \ - -w ~/work/ +$ vcspull add ~/study/python/pytest-docker -f ~/configs/python.yaml ``` ## Handling duplicates -vcspull merges duplicate workspace sections by default so existing repositories -stay intact. When conflicts appear, the command logs what it kept. Prefer to -resolve duplicates yourself? Pass `--no-merge` to leave every section untouched -while still surfacing warnings. +vcspull merges duplicate workspace sections before writing so existing +repositories stay intact. When it collapses multiple sections, the command logs +a summary of the merge. Prefer to inspect duplicates yourself? Add +`--no-merge` to keep every section untouched. ## After adding repositories -After adding repositories, consider: - -1. Running `vcspull fmt --write` to normalize and sort your configuration (see {ref}`cli-fmt`) -2. Running `vcspull list` to verify the repository was added correctly (see {ref}`cli-list`) -3. Running `vcspull sync` to clone the repository (see {ref}`cli-sync`) +1. Run `vcspull fmt --write` to normalize your configuration (see + {ref}`cli-fmt`). +2. Run `vcspull list` to verify the new entry (see {ref}`cli-list`). +3. Run `vcspull sync` to clone or update the working tree (see {ref}`cli-sync`). ## Migration from vcspull import -If you previously used `vcspull import `: +If you previously used `vcspull import `, switch to the path-first +workflow: ```diff - $ vcspull import flask https://github.com/pallets/flask.git -c ~/.vcspull.yaml -+ $ vcspull add flask https://github.com/pallets/flask.git -f ~/.vcspull.yaml ++ $ vcspull add ~/code/flask --url https://github.com/pallets/flask.git -f ~/.vcspull.yaml ``` -Changes: -- Command name: `import` → `add` -- Config flag: `-c` → `-f` -- Same functionality otherwise +Key differences: + +- `vcspull add` now 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. [pip vcs url]: https://pip.pypa.io/en/stable/topics/vcs-support/