From dbdd8972ea8938f6d493bd497f2e22b2bff1a28f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 12:07:10 -0500 Subject: [PATCH 01/11] py(deps) Require libvcs>=0.42.0,<0.43.0 why: libvcs 0.42.0 adds an arbitrary GitSync clone depth (depth=N) and fixes a constructor bug that dropped git_shallow/tls_verify. vcspull needs the depth argument to persist and apply a numeric depth: N per repository (issue #552); the prior <0.42.0 pin cannot honor it. what: - Bump the libvcs dependency floor to >=0.42.0,<0.43.0 - Relock uv.lock (libvcs 0.41.0 -> 0.42.0) --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 16b72fa7..ad3ff27f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ keywords = [ ] homepage = "https://vcspull.git-pull.com" dependencies = [ - "libvcs>=0.41.0,<0.42.0", + "libvcs>=0.42.0,<0.43.0", "colorama>=0.3.9", "PyYAML>=6.0" ] diff --git a/uv.lock b/uv.lock index 6ec1197c..b3468056 100644 --- a/uv.lock +++ b/uv.lock @@ -619,14 +619,14 @@ wheels = [ [[package]] name = "libvcs" -version = "0.41.0" +version = "0.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/fd/71d6d983e3292c0d55d2755b823d1f4159ddbd5ed422735bf9def0bc0ee9/libvcs-0.41.0.tar.gz", hash = "sha256:e5af9dba8f524d61349c4ebd93451fde9d4c996a77fb52cae7f9ea2b970d1137", size = 623333, upload-time = "2026-05-10T12:51:14.768Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/fc/9b3ded35c251d77780cec94dc8fa3eeb0a80b26cbe1efcdf07d973adeb17/libvcs-0.42.0.tar.gz", hash = "sha256:8744756916df1d623d056a52ad19c37e3787091570bf35a11f4f33aeba82badc", size = 631217, upload-time = "2026-05-31T15:22:05.004Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/97/4e274f0bbc9c888cf042e60957fa7fc89618ff86a64b07e3a99792048218/libvcs-0.41.0-py3-none-any.whl", hash = "sha256:e5c38226b0b30d723649039bead365217be345b7c3caf4eb3fda18a1c8facbea", size = 101680, upload-time = "2026-05-10T12:51:12.76Z" }, + { url = "https://files.pythonhosted.org/packages/06/93/3a602d3cf8fe1cf66f75e4002ff1d5c6163932aa9815166770b4b697b661/libvcs-0.42.0-py3-none-any.whl", hash = "sha256:83e46a834e5138e770b0787eafbf0761561ab8776152eee4da8d388a59f154bc", size = 102080, upload-time = "2026-05-31T15:22:03.312Z" }, ] [[package]] @@ -1770,7 +1770,7 @@ typings = [ [package.metadata] requires-dist = [ { name = "colorama", specifier = ">=0.3.9" }, - { name = "libvcs", specifier = ">=0.41.0,<0.42.0" }, + { name = "libvcs", specifier = ">=0.42.0,<0.43.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] From 9740c7d24dfc90a926cf6626a8bb534f9b9842dd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 12:14:10 -0500 Subject: [PATCH 02/11] config(options[schema]) Read rev/shallow/depth under options: + add depth helpers why: Issue #552 adds a numeric clone depth and relocates the per-repo sync-tuning keys (rev/shallow/depth) into the existing options: block, keeping them out of the entry root. The reader must accept the new canonical form while still honoring v1.61.0's top-level keys, and the add/discover/migrate paths need shared primitives for depth. what: - types: add rev/shallow/depth to RepoOptionsDict; add depth to ConfigDict; mark top-level rev/shallow in RepoEntryDict as deprecated - config.extract_repos: lift options.{rev,shallow,depth} onto the flat ConfigDict (options wins over a legacy top-level key) - config.detect_git_depth: report a shallow checkout's commit count - config.resolve_clone_depth: shared hybrid precedence for add/discover - config.migrate_repo_entry: relocate legacy top-level keys under options: (depth wins over shallow) - config.detect_legacy_repo_options: find entries still using top-level keys (drives the deprecation warning) - tests: cover the reader, helpers, and migration with parametrized NamedTuple fixtures --- src/vcspull/config.py | 254 ++++++++++++++++++++++++++++++++ src/vcspull/types.py | 58 ++++++-- tests/test_config.py | 330 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 629 insertions(+), 13 deletions(-) diff --git a/src/vcspull/config.py b/src/vcspull/config.py index 921faa30..fab361e0 100644 --- a/src/vcspull/config.py +++ b/src/vcspull/config.py @@ -339,6 +339,16 @@ def extract_repos( else: conf.pop("repo", None) + # Sync-tuning keys (rev/shallow/depth) are canonical under + # ``options:``; lift them onto the flat ConfigDict the sync path + # reads. A legacy top-level key was already copied above by + # update_dict, but an ``options:`` value wins when both are set. + entry_options = conf.get("options") + if isinstance(entry_options, dict): + for option_key in LEGACY_REPO_OPTION_KEYS: + if option_key in entry_options: + conf[option_key] = entry_options[option_key] + if "name" not in conf: conf["name"] = repo @@ -900,6 +910,134 @@ def detect_git_shallow(repo_path: pathlib.Path) -> bool: return result.stdout.strip() == "true" +def detect_git_depth(repo_path: pathlib.Path) -> int | None: + """Return the clone depth of a shallow git checkout, else ``None``. + + A full (non-shallow) checkout returns ``None``. A shallow checkout returns + the number of commits reachable from ``HEAD`` (``git rev-list --count + HEAD``), which equals the ``--depth`` used to clone a linear history. Any + error (missing binary, non-git path, unparsable output) is treated as + "cannot determine" and returns ``None``. + + Parameters + ---------- + repo_path : pathlib.Path + Path to the local git repository. + + Returns + ------- + int | None + Commit count of a shallow checkout, or ``None`` when full or unknown. + + Examples + -------- + Seed a remote with enough history that a ``--depth 2`` clone is shallow: + + >>> remote = create_git_remote_repo() + >>> for message in ("two", "three"): + ... _ = subprocess.run( + ... ["git", "-C", str(remote), "commit", "-q", "--allow-empty", + ... "-m", message], + ... check=True, capture_output=True, + ... ) + + A full clone has no depth: + + >>> full = tmp_path / "full" + >>> _ = subprocess.run( + ... ["git", "clone", f"file://{remote}", str(full)], + ... check=True, capture_output=True, + ... ) + >>> detect_git_depth(full) is None + True + + A ``--depth 2`` clone reports its depth: + + >>> shallow = tmp_path / "shallow" + >>> _ = subprocess.run( + ... ["git", "clone", "--depth", "2", f"file://{remote}", str(shallow)], + ... check=True, capture_output=True, + ... ) + >>> detect_git_depth(shallow) + 2 + """ + if not detect_git_shallow(repo_path): + return None + + try: + result = subprocess.run( + ["git", "-C", str(repo_path), "rev-list", "--count", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError): + return None + + try: + return int(result.stdout.strip()) + except ValueError: + return None + + +def resolve_clone_depth( + repo_path: pathlib.Path, + *, + explicit_shallow: bool = False, + explicit_depth: int | None = None, +) -> tuple[bool, int | None]: + """Resolve the ``(shallow, depth)`` to record for a checkout. + + Centralizes the precedence shared by ``add`` and ``discover`` so the two + subcommands stay consistent. Precedence, highest first: + + 1. ``explicit_depth`` (from ``--depth N``) → ``(False, explicit_depth)``. + 2. ``explicit_shallow`` (from ``--shallow``) → ``(True, None)``. + 3. Auto-detected depth (hybrid): a depth-1 checkout records ``shallow: + true`` (the common case), depth > 1 records ``depth: N``, and a full + checkout records neither. + + Parameters + ---------- + repo_path : pathlib.Path + Path to the local git checkout to inspect when auto-detecting. + explicit_shallow : bool + Whether ``--shallow`` was passed. + explicit_depth : int | None + Value of ``--depth N``, or ``None`` when not passed. + + Returns + ------- + tuple[bool, int | None] + ``(shallow, depth)`` to hand to :func:`build_repo_entry`. + + Examples + -------- + Explicit flags win and never touch the filesystem: + + >>> resolve_clone_depth(tmp_path, explicit_depth=5) + (False, 5) + >>> resolve_clone_depth(tmp_path, explicit_shallow=True) + (True, None) + + A path that is not a shallow checkout records neither: + + >>> resolve_clone_depth(tmp_path) + (False, None) + """ + if explicit_depth is not None: + return False, explicit_depth + if explicit_shallow: + return True, None + + detected = detect_git_depth(repo_path) + if detected is None: + return False, None + if detected <= 1: + return True, None + return False, detected + + def build_repo_entry( url: str, *, @@ -944,6 +1082,122 @@ def build_repo_entry( return entry +#: Per-repository sync-tuning keys whose canonical home is the ``options:`` +#: block. They were accepted at the entry root in v1.61.0; that form is now +#: deprecated and migrated by :func:`migrate_repo_entry`. +LEGACY_REPO_OPTION_KEYS = ("rev", "shallow", "depth") + + +def migrate_repo_entry(entry: t.Any) -> tuple[bool, t.Any]: + """Relocate legacy top-level sync keys under ``options:``. + + Moves any top-level ``rev``/``shallow``/``depth`` into the entry's + ``options:`` block. A value already present under ``options:`` wins, so the + redundant top-level copy is simply dropped. When both ``shallow`` and a + truthy ``depth`` end up under ``options:``, ``depth`` wins and ``shallow`` + is removed (matching how sync resolves precedence). + + Parameters + ---------- + entry : Any + A raw repository entry (string shorthand or mapping). + + Returns + ------- + tuple[bool, Any] + ``(changed, entry)``. ``changed`` is ``False`` (and the entry returned + unchanged) for string shorthands and mappings with no legacy keys. + + Examples + -------- + String shorthands and already-migrated entries are untouched: + + >>> migrate_repo_entry("git+ssh://x") + (False, 'git+ssh://x') + >>> migrate_repo_entry({"repo": "git+ssh://x"}) + (False, {'repo': 'git+ssh://x'}) + + A legacy top-level key is relocated: + + >>> migrate_repo_entry({"repo": "git+ssh://x", "shallow": True}) + (True, {'repo': 'git+ssh://x', 'options': {'shallow': True}}) + + ``depth`` wins over ``shallow`` in the migrated entry: + + >>> migrate_repo_entry( + ... {"repo": "git+ssh://x", "rev": "v1", "shallow": True, "depth": 5} + ... ) + (True, {'repo': 'git+ssh://x', 'options': {'rev': 'v1', 'depth': 5}}) + """ + if not isinstance(entry, dict): + return False, entry + + if not any(key in entry for key in LEGACY_REPO_OPTION_KEYS): + return False, entry + + new_entry = copy.deepcopy(entry) + options: dict[str, t.Any] = dict(new_entry.get("options") or {}) + for key in LEGACY_REPO_OPTION_KEYS: + if key not in new_entry: + continue + value = new_entry.pop(key) + options.setdefault(key, value) + + if options.get("depth"): + options.pop("shallow", None) + + new_entry["options"] = options + return True, new_entry + + +def detect_legacy_repo_options(raw_config: t.Any) -> list[tuple[str, str]]: + """Return ``(workspace_label, repo_name)`` pairs using legacy top-level keys. + + Scans a raw (unexpanded) config mapping for repository entries that still + carry top-level ``rev``/``shallow``/``depth`` instead of nesting them under + ``options:``. Callers use the result to warn users to run ``vcspull + migrate``. + + Parameters + ---------- + raw_config : Any + Raw config mapping (workspace root → repo name → entry). + + Returns + ------- + list[tuple[str, str]] + One ``(workspace_label, repo_name)`` pair per legacy entry. + + Examples + -------- + >>> detect_legacy_repo_options( + ... {"~/code/": {"flask": {"repo": "git+x", "shallow": True}}} + ... ) + [('~/code/', 'flask')] + + The canonical ``options:`` form is not flagged: + + >>> detect_legacy_repo_options( + ... {"~/code/": {"flask": {"repo": "git+x", "options": {"shallow": True}}}} + ... ) + [] + """ + legacy: list[tuple[str, str]] = [] + if not isinstance(raw_config, dict): + return legacy + + for workspace_label, repos in raw_config.items(): + if not isinstance(repos, dict): + continue + for repo_name, entry in repos.items(): + if isinstance(entry, dict) and any( + key in entry for key in LEGACY_REPO_OPTION_KEYS + ): + legacy.append((str(workspace_label), str(repo_name))) + + return legacy + + def save_config(config_file_path: pathlib.Path, data: dict[t.Any, t.Any]) -> None: """Save configuration data, dispatching by file extension. diff --git a/src/vcspull/types.py b/src/vcspull/types.py index 58248635..292eb1b8 100644 --- a/src/vcspull/types.py +++ b/src/vcspull/types.py @@ -122,13 +122,26 @@ class WorktreeConfigDict(TypedDict): class RepoOptionsDict(TypedDict, total=False): - """Mutation policy stored under the ``options:`` key in a repo entry. + """Per-repository options stored under the ``options:`` key in a repo entry. + + Two groups of keys live here: + + - **Sync tuning** (``rev``, ``shallow``, ``depth``) — forwarded to libvcs to + shape how the checkout is cloned/updated. + - **Mutation policy** (``pin``, ``allow_overwrite``, ``pin_reason``) — guards + whether vcspull's commands may rewrite this config entry. Note: ``pin`` here controls vcspull config mutation. It is distinct from ``WorktreeConfigDict.lock`` which prevents git worktree removal. Examples -------- + Pin to a ref and clone with a small history window:: + + options: + rev: v1.2.3 + depth: 50 + Pin all operations:: options: @@ -147,6 +160,25 @@ class RepoOptionsDict(TypedDict, total=False): allow_overwrite: false """ + rev: NotRequired[str] + """Commit, tag, or branch to check out on sync (libvcs ``rev``). + + Distinct from ``pin``, which guards config mutation rather than pinning a + git ref. + """ + + shallow: NotRequired[bool] + """If ``True``, clone with ``--depth 1`` on sync (libvcs ``git_shallow``). + + Sugar for ``depth: 1``; ``depth`` wins when both are set. + """ + + depth: NotRequired[int] + """Clone with history truncated to ``depth`` commits (libvcs ``depth``). + + Takes precedence over ``shallow``. + """ + pin: bool | RepoPinDict """``True`` pins all ops; a mapping pins specific ops only. @@ -172,15 +204,12 @@ class RepoEntryDict(TypedDict): repo: git+git@github.com:user/myrepo.git - Pinned to a ref:: - - repo: git+git@github.com:user/myrepo.git - rev: v1.2.3 - - Shallow clone:: + Pinned to a ref and shallow-cloned:: repo: git+git@github.com:user/myrepo.git - shallow: true + options: + rev: v1.2.3 + depth: 50 With pin options:: @@ -195,17 +224,19 @@ class RepoEntryDict(TypedDict): """VCS URL in vcspull format, e.g. ``git+git@github.com:user/repo.git``.""" rev: NotRequired[str] - """Commit, tag, or branch to check out on sync (libvcs ``rev``). + """Deprecated top-level form of ``options.rev``; still read, with a warning. - Distinct from ``options.pin``, which guards config mutation rather than - pinning a git ref. + Run ``vcspull migrate`` to relocate it under ``options:``. """ shallow: NotRequired[bool] - """If ``True``, clone with ``--depth 1`` on sync (libvcs ``git_shallow``).""" + """Deprecated top-level form of ``options.shallow``; still read, with a warning. + + Run ``vcspull migrate`` to relocate it under ``options:``. + """ options: NotRequired[RepoOptionsDict] - """Mutation policy. Nested under ``options:`` to avoid polluting VCS fields.""" + """Sync tuning (``rev``/``shallow``/``depth``) plus mutation policy.""" class RawConfigDict(t.TypedDict): @@ -232,6 +263,7 @@ class ConfigDict(TypedDict): workspace_root: str rev: NotRequired[str | None] shallow: NotRequired[bool | None] + depth: NotRequired[int | None] remotes: NotRequired[GitSyncRemoteDict | None] shell_command_after: NotRequired[list[str] | None] worktrees: NotRequired[list[WorktreeConfigDict] | None] diff --git a/tests/test_config.py b/tests/test_config.py index 5ad40cbf..7f7c0299 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import subprocess import textwrap import typing as t @@ -12,12 +13,18 @@ from vcspull.config import ( MergeAction, _classify_merge_action, + detect_git_depth, + detect_legacy_repo_options, merge_duplicate_workspace_root_entries, + migrate_repo_entry, + resolve_clone_depth, ) if t.TYPE_CHECKING: import pathlib + from libvcs.pytest_plugin import CreateRepoFn + from vcspull.types import ConfigDict, RawConfigDict @@ -365,3 +372,326 @@ def test_merge_duplicate_workspace_root_entries_conflicts( f"Expected '{fragment}' in conflicts for {test_id}, " f"got: {all_conflict_text}" ) + + +# --------------------------------------------------------------------------- +# options: sync-tuning keys (rev/shallow/depth) +# --------------------------------------------------------------------------- + + +def _seed_commits(repo_path: pathlib.Path, count: int) -> None: + """Add ``count`` empty commits to a git checkout.""" + for index in range(count): + subprocess.run( + [ + "git", + "-C", + str(repo_path), + "commit", + "-q", + "--allow-empty", + "-m", + f"commit-{index}", + ], + check=True, + capture_output=True, + ) + + +class ExtractOptionsFixture(t.NamedTuple): + """Fixture for extract_repos lifting sync keys onto the flat ConfigDict.""" + + test_id: str + raw_config: dict[str, t.Any] + expected: dict[str, t.Any] + + +EXTRACT_OPTIONS_FIXTURES: list[ExtractOptionsFixture] = [ + ExtractOptionsFixture( + test_id="options-canonical", + raw_config={ + "~/code/": { + "flask": { + "repo": "git+https://example.com/flask.git", + "options": {"rev": "v3.0.0", "depth": 50}, + }, + }, + }, + expected={"rev": "v3.0.0", "depth": 50}, + ), + ExtractOptionsFixture( + test_id="legacy-top-level", + raw_config={ + "~/code/": { + "flask": { + "repo": "git+https://example.com/flask.git", + "rev": "v1.0.0", + "shallow": True, + }, + }, + }, + expected={"rev": "v1.0.0", "shallow": True}, + ), + ExtractOptionsFixture( + test_id="options-wins-over-legacy", + raw_config={ + "~/code/": { + "flask": { + "repo": "git+https://example.com/flask.git", + "rev": "legacy", + "depth": 10, + "options": {"rev": "canonical", "depth": 99}, + }, + }, + }, + expected={"rev": "canonical", "depth": 99}, + ), +] + + +@pytest.mark.parametrize( + list(ExtractOptionsFixture._fields), + EXTRACT_OPTIONS_FIXTURES, + ids=[f.test_id for f in EXTRACT_OPTIONS_FIXTURES], +) +def test_extract_repos_lifts_options_sync_keys( + test_id: str, + raw_config: dict[str, t.Any], + expected: dict[str, t.Any], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """extract_repos surfaces options/legacy sync keys on the flat ConfigDict.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + typed_raw_config = t.cast("RawConfigDict", raw_config) + repos = config.extract_repos(typed_raw_config, cwd=tmp_path) + + assert len(repos) == 1 + repo = t.cast("dict[str, t.Any]", repos[0]) + for key, value in expected.items(): + assert repo[key] == value + + +class ResolveDepthFixture(t.NamedTuple): + """Fixture for resolve_clone_depth explicit-flag precedence.""" + + test_id: str + explicit_shallow: bool + explicit_depth: int | None + expected: tuple[bool, int | None] + + +RESOLVE_DEPTH_FIXTURES: list[ResolveDepthFixture] = [ + ResolveDepthFixture("explicit-depth", False, 5, (False, 5)), + ResolveDepthFixture("explicit-depth-beats-shallow", True, 5, (False, 5)), + ResolveDepthFixture("explicit-shallow", True, None, (True, None)), + ResolveDepthFixture("no-flags-non-git", False, None, (False, None)), +] + + +@pytest.mark.parametrize( + list(ResolveDepthFixture._fields), + RESOLVE_DEPTH_FIXTURES, + ids=[f.test_id for f in RESOLVE_DEPTH_FIXTURES], +) +def test_resolve_clone_depth_explicit( + test_id: str, + explicit_shallow: bool, + explicit_depth: int | None, + expected: tuple[bool, int | None], + tmp_path: pathlib.Path, +) -> None: + """Explicit flags resolve without inspecting the filesystem.""" + result = resolve_clone_depth( + tmp_path, + explicit_shallow=explicit_shallow, + explicit_depth=explicit_depth, + ) + assert result == expected + + +def test_resolve_clone_depth_autodetect( + tmp_path: pathlib.Path, + create_git_remote_repo: CreateRepoFn, +) -> None: + """Hybrid auto-detect: depth-1 -> shallow, depth>1 -> numeric, full -> none.""" + remote = create_git_remote_repo() + _seed_commits(remote, 4) + + full = tmp_path / "full" + subprocess.run( + ["git", "clone", "-q", f"file://{remote}", str(full)], + check=True, + capture_output=True, + ) + assert resolve_clone_depth(full) == (False, None) + + shallow_one = tmp_path / "shallow_one" + subprocess.run( + ["git", "clone", "-q", "--depth", "1", f"file://{remote}", str(shallow_one)], + check=True, + capture_output=True, + ) + assert resolve_clone_depth(shallow_one) == (True, None) + + shallow_three = tmp_path / "shallow_three" + subprocess.run( + ["git", "clone", "-q", "--depth", "3", f"file://{remote}", str(shallow_three)], + check=True, + capture_output=True, + ) + assert resolve_clone_depth(shallow_three) == (False, 3) + + +def test_detect_git_depth( + tmp_path: pathlib.Path, + create_git_remote_repo: CreateRepoFn, +) -> None: + """detect_git_depth returns the commit count for shallow checkouts only.""" + assert detect_git_depth(tmp_path) is None # not a git repo + + remote = create_git_remote_repo() + _seed_commits(remote, 4) + + full = tmp_path / "full" + subprocess.run( + ["git", "clone", "-q", f"file://{remote}", str(full)], + check=True, + capture_output=True, + ) + assert detect_git_depth(full) is None + + shallow = tmp_path / "shallow" + subprocess.run( + ["git", "clone", "-q", "--depth", "3", f"file://{remote}", str(shallow)], + check=True, + capture_output=True, + ) + assert detect_git_depth(shallow) == 3 + + +class MigrateEntryFixture(t.NamedTuple): + """Fixture for migrate_repo_entry top-level -> options relocation.""" + + test_id: str + entry: t.Any + expected_changed: bool + expected_entry: t.Any + + +MIGRATE_ENTRY_FIXTURES: list[MigrateEntryFixture] = [ + MigrateEntryFixture( + test_id="string-passthrough", + entry="git+ssh://x", + expected_changed=False, + expected_entry="git+ssh://x", + ), + MigrateEntryFixture( + test_id="no-legacy-keys", + entry={"repo": "git+ssh://x", "options": {"pin": True}}, + expected_changed=False, + expected_entry={"repo": "git+ssh://x", "options": {"pin": True}}, + ), + MigrateEntryFixture( + test_id="single-legacy-shallow", + entry={"repo": "git+ssh://x", "shallow": True}, + expected_changed=True, + expected_entry={"repo": "git+ssh://x", "options": {"shallow": True}}, + ), + MigrateEntryFixture( + test_id="depth-wins-over-shallow", + entry={"repo": "git+ssh://x", "rev": "v1", "shallow": True, "depth": 5}, + expected_changed=True, + expected_entry={"repo": "git+ssh://x", "options": {"rev": "v1", "depth": 5}}, + ), + MigrateEntryFixture( + test_id="options-value-wins", + entry={"repo": "git+ssh://x", "rev": "legacy", "options": {"rev": "canonical"}}, + expected_changed=True, + expected_entry={"repo": "git+ssh://x", "options": {"rev": "canonical"}}, + ), + MigrateEntryFixture( + test_id="preserves-pin-options", + entry={"repo": "git+ssh://x", "shallow": True, "options": {"pin": True}}, + expected_changed=True, + expected_entry={ + "repo": "git+ssh://x", + "options": {"pin": True, "shallow": True}, + }, + ), +] + + +@pytest.mark.parametrize( + list(MigrateEntryFixture._fields), + MIGRATE_ENTRY_FIXTURES, + ids=[f.test_id for f in MIGRATE_ENTRY_FIXTURES], +) +def test_migrate_repo_entry( + test_id: str, + entry: t.Any, + expected_changed: bool, + expected_entry: t.Any, +) -> None: + """migrate_repo_entry relocates legacy keys under options:, depth wins.""" + changed, result = migrate_repo_entry(entry) + assert changed is expected_changed + assert result == expected_entry + + +class LegacyOptionsFixture(t.NamedTuple): + """Fixture for detect_legacy_repo_options scanning.""" + + test_id: str + raw_config: t.Any + expected: list[tuple[str, str]] + + +LEGACY_OPTIONS_FIXTURES: list[LegacyOptionsFixture] = [ + LegacyOptionsFixture( + test_id="legacy-shallow-flagged", + raw_config={"~/code/": {"flask": {"repo": "git+x", "shallow": True}}}, + expected=[("~/code/", "flask")], + ), + LegacyOptionsFixture( + test_id="canonical-not-flagged", + raw_config={"~/code/": {"flask": {"repo": "git+x", "options": {"depth": 5}}}}, + expected=[], + ), + LegacyOptionsFixture( + test_id="string-entry-not-flagged", + raw_config={"~/code/": {"flask": "git+x"}}, + expected=[], + ), + LegacyOptionsFixture( + test_id="mixed-only-legacy-flagged", + raw_config={ + "~/code/": { + "flask": {"repo": "git+x", "rev": "v1"}, + "django": {"repo": "git+y", "options": {"depth": 5}}, + }, + }, + expected=[("~/code/", "flask")], + ), + LegacyOptionsFixture( + test_id="non-dict-input", + raw_config="not-a-dict", + expected=[], + ), +] + + +@pytest.mark.parametrize( + list(LegacyOptionsFixture._fields), + LEGACY_OPTIONS_FIXTURES, + ids=[f.test_id for f in LEGACY_OPTIONS_FIXTURES], +) +def test_detect_legacy_repo_options( + test_id: str, + raw_config: t.Any, + expected: list[tuple[str, str]], +) -> None: + """detect_legacy_repo_options reports only entries with top-level keys.""" + assert detect_legacy_repo_options(raw_config) == expected From 14f2e6ffeebeeb6ee56c436c66b062ce8f7a1731 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 12:24:22 -0500 Subject: [PATCH 03/11] cli(add,discover,sync) Support --depth N and write keys under options: why: Issue #552 lets workspaces keep a small history window (e.g. --depth 50) instead of only the boolean shallow flag, and relocates the per-repo sync keys into options:. libvcs 0.42.0 honors GitSync(depth=N), so add/discover can persist it and sync can apply it. what: - build_repo_entry: nest rev/shallow/depth under options:; depth wins over shallow - add: add --depth N (validated >=1); resolve_clone_depth replaces the inline shallow check (hybrid auto-detect); thread depth to add_repo - discover: add --depth N; carry depth on _FoundRepo; per-repo resolve_clone_depth; log and persist depth - sync.update_repo: apply options.depth as GitSync.depth (git-gated, depth wins); refresh the git_shallow comment for libvcs 0.42.0 - sync: load_configs(warn_legacy_options=True) warns on top-level rev/shallow/depth and points at vcspull migrate - tests: depth cases for add/discover, sync depth + deprecation-warning coverage; existing assertions moved to the options: form --- src/vcspull/cli/__init__.py | 1 + src/vcspull/cli/add.py | 49 ++++++++++++++++--- src/vcspull/cli/discover.py | 88 +++++++++++++++++++++++++++------ src/vcspull/cli/sync.py | 24 ++++++--- src/vcspull/config.py | 50 ++++++++++++++++--- tests/cli/test_add.py | 48 +++++++++++++++++- tests/cli/test_discover.py | 98 +++++++++++++++++++++++++++++++++++-- tests/test_cli.py | 2 +- tests/test_sync.py | 79 +++++++++++++++++++++++++++++- 9 files changed, 391 insertions(+), 48 deletions(-) diff --git a/src/vcspull/cli/__init__.py b/src/vcspull/cli/__init__.py index aa047bff..ecc432c6 100644 --- a/src/vcspull/cli/__init__.py +++ b/src/vcspull/cli/__init__.py @@ -540,6 +540,7 @@ def cli(_args: list[str] | None = None) -> None: include_worktrees=getattr(args, "include_worktrees", False), rev=getattr(args, "pin", None), shallow=getattr(args, "shallow", False), + depth=getattr(args, "depth", None), ) elif args.subparser_name == "fmt": format_config_file( diff --git a/src/vcspull/cli/add.py b/src/vcspull/cli/add.py index ce2f010f..849b1bac 100644 --- a/src/vcspull/cli/add.py +++ b/src/vcspull/cli/add.py @@ -21,13 +21,13 @@ from vcspull.config import ( build_repo_entry, canonicalize_workspace_path, - detect_git_shallow, expand_dir, find_home_config_files, get_pin_reason, is_pinned_for_op, merge_duplicate_workspace_roots, normalize_config_file_path, + resolve_clone_depth, save_config, save_config_json, save_config_yaml_with_items, @@ -124,8 +124,19 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None: dest="shallow", action="store_true", help=( - "Record 'shallow: true' (clone --depth 1 on sync). A shallow " - "checkout is detected automatically; this forces it on." + "Record 'options.shallow: true' (clone --depth 1 on sync). A " + "shallow checkout is detected automatically; this forces it on." + ), + ) + parser.add_argument( + "--depth", + dest="depth", + type=int, + metavar="N", + help=( + "Record 'options.depth: N' (clone --depth N on sync). Overrides " + "--shallow. An existing shallow checkout's depth is detected " + "automatically." ), ) parser.add_argument( @@ -465,8 +476,26 @@ def handle_add_command(args: argparse.Namespace) -> None: Style.RESET_ALL, ) - shallow = bool(getattr(args, "shallow", False)) or detect_git_shallow(repo_path) - if shallow: + explicit_depth = getattr(args, "depth", None) + if explicit_depth is not None and explicit_depth < 1: + log.error("--depth must be a positive integer (got %s)", explicit_depth) + return + + shallow, depth = resolve_clone_depth( + repo_path, + explicit_shallow=bool(getattr(args, "shallow", False)), + explicit_depth=explicit_depth, + ) + if depth is not None: + log.info( + " %s•%s depth: %s%s%s", + Fore.BLUE, + Style.RESET_ALL, + Fore.YELLOW, + depth, + Style.RESET_ALL, + ) + elif shallow: log.info( " %s•%s shallow: %strue%s", Fore.BLUE, @@ -518,6 +547,7 @@ def handle_add_command(args: argparse.Namespace) -> None: merge_duplicates=args.merge_duplicates, rev=getattr(args, "pin", None), shallow=shallow, + depth=depth, ) @@ -532,6 +562,7 @@ def add_repo( merge_duplicates: bool = True, rev: str | None = None, shallow: bool = False, + depth: int | None = None, ) -> None: """Add a repository to the vcspull configuration. @@ -550,9 +581,11 @@ def add_repo( dry_run : bool If True, preview changes without writing rev : str | None - Commit, tag, or branch to record as the repository ``rev``. + Commit, tag, or branch to record as ``options.rev``. shallow : bool - If ``True``, record ``shallow: true`` for the repository. + If ``True``, record ``options.shallow: true`` for the repository. + depth : int | None + If set, record ``options.depth: N`` for the repository. """ # Determine config file config_file_path: pathlib.Path @@ -630,7 +663,7 @@ def add_repo( preserve_cwd_label=explicit_dot, ) - new_repo_entry = build_repo_entry(url, rev=rev, shallow=shallow) + new_repo_entry = build_repo_entry(url, rev=rev, shallow=shallow, depth=depth) def _ensure_workspace_label_for_merge( config_data: dict[str, t.Any], diff --git a/src/vcspull/cli/discover.py b/src/vcspull/cli/discover.py index 95759785..5d91b7a6 100644 --- a/src/vcspull/cli/discover.py +++ b/src/vcspull/cli/discover.py @@ -18,7 +18,6 @@ from vcspull.config import ( build_repo_entry, canonicalize_workspace_path, - detect_git_shallow, expand_dir, find_home_config_files, get_pin_reason, @@ -26,6 +25,7 @@ merge_duplicate_workspace_roots, normalize_config_file_path, normalize_workspace_roots, + resolve_clone_depth, save_config, workspace_root_label, ) @@ -42,12 +42,13 @@ class DiscoverAction(enum.Enum): class _FoundRepo(t.NamedTuple): - """A git checkout found while scanning, with its resolved shallow state.""" + """A git checkout found while scanning, with its resolved clone depth.""" name: str url: str workspace_path: pathlib.Path shallow: bool + depth: int | None def _classify_discover_action(existing_entry: t.Any) -> DiscoverAction: @@ -259,8 +260,19 @@ def create_discover_subparser(parser: argparse.ArgumentParser) -> None: dest="shallow", action="store_true", help=( - "Record 'shallow: true' for discovered repositories. Shallow " - "checkouts are detected automatically; this forces it on for all." + "Record 'options.shallow: true' for discovered repositories. " + "Shallow checkouts are detected automatically; this forces it on " + "for all." + ), + ) + parser.add_argument( + "--depth", + dest="depth", + type=int, + metavar="N", + help=( + "Record 'options.depth: N' for every discovered repository " + "(clone --depth N on sync). Overrides --shallow." ), ) parser.add_argument( @@ -337,6 +349,7 @@ def discover_repos( include_worktrees: bool = False, rev: str | None = None, shallow: bool = False, + depth: int | None = None, ) -> None: """Scan filesystem for git repositories and add to vcspull config. @@ -355,12 +368,19 @@ def discover_repos( dry_run : bool If True, preview changes without writing rev : str | None - Commit, tag, or branch to record as the ``rev`` for every discovered - repository. + Commit, tag, or branch to record as ``options.rev`` for every + discovered repository. shallow : bool - If ``True``, force ``shallow: true`` for every discovered repository; - otherwise shallow state is auto-detected per repository. + If ``True``, force ``options.shallow: true`` for every discovered + repository; otherwise clone depth is auto-detected per repository. + depth : int | None + If set, record ``options.depth: N`` for every discovered repository; + overrides ``shallow``. """ + if depth is not None and depth < 1: + log.error("--depth must be a positive integer (got %s)", depth) + return + scan_dir = expand_dir(pathlib.Path(scan_dir_str)) config_file_path: pathlib.Path @@ -554,9 +574,19 @@ def discover_repos( continue workspace_path = override_workspace_path or scan_dir - repo_shallow = shallow or detect_git_shallow(repo_path) + repo_shallow, repo_depth = resolve_clone_depth( + repo_path, + explicit_shallow=shallow, + explicit_depth=depth, + ) found_repos.append( - _FoundRepo(repo_name, repo_url, workspace_path, repo_shallow), + _FoundRepo( + repo_name, + repo_url, + workspace_path, + repo_shallow, + repo_depth, + ), ) else: for item in scan_dir.iterdir(): @@ -582,9 +612,19 @@ def discover_repos( continue workspace_path = override_workspace_path or scan_dir - repo_shallow = shallow or detect_git_shallow(item) + repo_shallow, repo_depth = resolve_clone_depth( + item, + explicit_shallow=shallow, + explicit_depth=depth, + ) found_repos.append( - _FoundRepo(repo_name, repo_url, workspace_path, repo_shallow), + _FoundRepo( + repo_name, + repo_url, + workspace_path, + repo_shallow, + repo_depth, + ), ) if not found_repos: @@ -655,7 +695,7 @@ def discover_repos( len(existing_repos), Style.RESET_ALL, ) - for name, url, workspace_path, _shallow in existing_repos: + for name, url, workspace_path, _shallow, _depth in existing_repos: workspace_label = workspace_map.get(workspace_path) if workspace_label is None: workspace_label = workspace_root_label( @@ -729,7 +769,13 @@ def discover_repos( "preview" if dry_run else "import", Style.RESET_ALL, ) - for repo_name, repo_url, _determined_base_key, repo_shallow in repos_to_add: + for ( + repo_name, + repo_url, + _determined_base_key, + repo_shallow, + repo_depth, + ) in repos_to_add: log.info( " %s+%s %s%s%s (%s%s%s)", Fore.GREEN, @@ -750,7 +796,16 @@ def discover_repos( rev, Style.RESET_ALL, ) - if repo_shallow: + if repo_depth is not None: + log.info( + " %s•%s depth: %s%s%s", + Fore.BLUE, + Style.RESET_ALL, + Fore.YELLOW, + repo_depth, + Style.RESET_ALL, + ) + elif repo_shallow: log.info( " %s•%s shallow: %strue%s", Fore.BLUE, @@ -778,7 +833,7 @@ def discover_repos( log.info("%s✗%s Aborted by user.", Fore.RED, Style.RESET_ALL) return - for repo_name, repo_url, workspace_path, repo_shallow in repos_to_add: + for repo_name, repo_url, workspace_path, repo_shallow, repo_depth in repos_to_add: workspace_label = workspace_map.get(workspace_path) if workspace_label is None: workspace_label = workspace_root_label( @@ -804,6 +859,7 @@ def discover_repos( repo_url, rev=rev, shallow=repo_shallow, + depth=repo_depth, ) log.info( "%s+%s Importing %s'%s'%s (%s%s%s) under '%s%s%s'.", diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py index c8e0dd24..55f45e1f 100644 --- a/src/vcspull/cli/sync.py +++ b/src/vcspull/cli/sync.py @@ -1346,9 +1346,12 @@ def _sync_impl( plan_config = SyncPlanConfig(fetch=bool(fetch and not offline), offline=offline) if config: - configs = load_configs([config]) + configs = load_configs([config], warn_legacy_options=True) else: - configs = load_configs(find_config_files(include_home=True)) + configs = load_configs( + find_config_files(include_home=True), + warn_legacy_options=True, + ) found_repos: list[ConfigDict] = [] unmatched_count = 0 @@ -1924,11 +1927,13 @@ def update_repo( repo_dict["progress_callback"] = progress_callback or progress_cb - # ``shallow`` is the vcspull-facing config key for a depth-1 clone. libvcs - # GitSync only initializes ``git_shallow`` from kwargs as a default and - # drops it when actually passed, so capture the flag here and apply it as an - # attribute after construction (below) rather than forwarding the kwarg. + # vcspull surfaces options.shallow/options.depth as flat ``shallow``/ + # ``depth`` keys on the ConfigDict. libvcs names them ``git_shallow``/ + # ``depth``; translate and apply them as attributes after construction so + # they reach obtain() (depth wins over shallow) without leaking git-only + # kwargs into the svn/hg sync constructors. git_shallow = bool(repo_dict.pop("shallow", False)) + git_depth = repo_dict.pop("depth", None) if repo_dict.get("vcs") is None: vcs = guess_vcs(url=repo_dict["url"]) @@ -1938,8 +1943,11 @@ def update_repo( repo_dict["vcs"] = vcs r: GitSync | HgSync | SvnSync = create_project(**repo_dict) - if git_shallow and isinstance(r, GitSync): - r.git_shallow = True + if isinstance(r, GitSync): + if git_shallow: + r.git_shallow = True + if git_depth is not None: + r.depth = git_depth if repo_dict.get("vcs") == "git": result = r.update_repo(set_remotes=True) else: diff --git a/src/vcspull/config.py b/src/vcspull/config.py index fab361e0..2e46280d 100644 --- a/src/vcspull/config.py +++ b/src/vcspull/config.py @@ -522,6 +522,7 @@ def load_configs( cwd: pathlib.Path | Callable[[], pathlib.Path] = pathlib.Path.cwd, *, merge_duplicates: bool = True, + warn_legacy_options: bool = False, ) -> list[ConfigDict]: """Return repos from a list of files. @@ -531,6 +532,10 @@ def load_configs( paths to config file cwd : pathlib.Path current path (pass down for :func:`extract_repos` + warn_legacy_options : bool + If ``True``, log a deprecation warning for entries that still carry + top-level ``rev``/``shallow``/``depth`` keys (see + :func:`detect_legacy_repo_options`). Returns ------- @@ -585,6 +590,18 @@ def load_configs( duplicate_list, ) + if warn_legacy_options: + legacy_entries = detect_legacy_repo_options(config_content) + if legacy_entries: + affected = ", ".join(f"{label}{name}" for label, name in legacy_entries) + log.warning( + "%s: top-level rev/shallow/depth are deprecated; move them " + "under 'options:' (run 'vcspull migrate'). Affected: %s", + file, + affected, + extra={"vcspull_config_path": str(file)}, + ) + assert is_valid_config(config_content) newrepos = extract_repos(config_content, cwd=cwd) @@ -1043,20 +1060,24 @@ def build_repo_entry( *, rev: str | None = None, shallow: bool = False, + depth: int | None = None, ) -> dict[str, t.Any]: """Build a raw per-repository config entry for ``add``/``discover``. Centralizes the entry shape written by both subcommands so the recorded - keys stay consistent. + keys stay consistent. Sync-tuning keys are nested under ``options:``; + ``depth`` wins over ``shallow`` when both are supplied. Parameters ---------- url : str VCS URL in vcspull format, e.g. ``git+https://github.com/u/r.git``. rev : str | None - Commit, tag, or branch to pin via the ``rev`` key. Omitted when falsy. + Commit, tag, or branch to pin via ``options.rev``. Omitted when falsy. shallow : bool - If ``True``, record ``shallow: true`` (clone ``--depth 1`` on sync). + If ``True``, record ``options.shallow: true`` (clone ``--depth 1``). + depth : int | None + If set, record ``options.depth: N`` (clone ``--depth N``). Returns ------- @@ -1069,16 +1090,29 @@ def build_repo_entry( {'repo': 'git+https://github.com/u/r.git'} >>> build_repo_entry("git+https://github.com/u/r.git", rev="v1.0.0") - {'repo': 'git+https://github.com/u/r.git', 'rev': 'v1.0.0'} + {'repo': 'git+https://github.com/u/r.git', 'options': {'rev': 'v1.0.0'}} >>> build_repo_entry("git+https://github.com/u/r.git", shallow=True) - {'repo': 'git+https://github.com/u/r.git', 'shallow': True} + {'repo': 'git+https://github.com/u/r.git', 'options': {'shallow': True}} + + >>> build_repo_entry("git+https://github.com/u/r.git", depth=50) + {'repo': 'git+https://github.com/u/r.git', 'options': {'depth': 50}} + + ``depth`` wins over ``shallow``: + + >>> build_repo_entry("git+https://github.com/u/r.git", shallow=True, depth=50) + {'repo': 'git+https://github.com/u/r.git', 'options': {'depth': 50}} """ entry: dict[str, t.Any] = {"repo": url} + options: dict[str, t.Any] = {} if rev: - entry["rev"] = rev - if shallow: - entry["shallow"] = True + options["rev"] = rev + if depth: + options["depth"] = depth + elif shallow: + options["shallow"] = True + if options: + entry["options"] = options return entry diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index 708d3ec2..0e844f19 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -45,6 +45,7 @@ class AddRepoFixture(t.NamedTuple): expected_log_messages: list[str] rev: str | None = None shallow: bool = False + depth: int | None = None def init_git_repo(repo_path: pathlib.Path, remote_url: str | None) -> None: @@ -176,7 +177,7 @@ def init_git_repo(repo_path: pathlib.Path, remote_url: str | None) -> None: "~/": { "pinnedproject": { "repo": "git+https://github.com/user/pinnedproject.git", - "rev": "v1.2.3", + "options": {"rev": "v1.2.3"}, }, }, }, @@ -196,13 +197,54 @@ def init_git_repo(repo_path: pathlib.Path, remote_url: str | None) -> None: "~/": { "shallowproject": { "repo": "git+https://github.com/user/shallowproject.git", - "shallow": True, + "options": {"shallow": True}, }, }, }, expected_log_messages=["Successfully added 'shallowproject'"], shallow=True, ), + AddRepoFixture( + test_id="add-with-depth", + name="depthproject", + url="git+https://github.com/user/depthproject.git", + workspace_root=None, + path_relative="depthproject", + dry_run=False, + use_default_config=False, + preexisting_config=None, + expected_in_config={ + "~/": { + "depthproject": { + "repo": "git+https://github.com/user/depthproject.git", + "options": {"depth": 50}, + }, + }, + }, + expected_log_messages=["Successfully added 'depthproject'"], + depth=50, + ), + AddRepoFixture( + test_id="add-depth-beats-shallow", + name="bothproject", + url="git+https://github.com/user/bothproject.git", + workspace_root=None, + path_relative="bothproject", + dry_run=False, + use_default_config=False, + preexisting_config=None, + expected_in_config={ + "~/": { + "bothproject": { + "repo": "git+https://github.com/user/bothproject.git", + "options": {"depth": 5}, + }, + }, + }, + expected_log_messages=["Successfully added 'bothproject'"], + shallow=True, + depth=5, + ), ] @@ -224,6 +266,7 @@ def test_add_repo( expected_log_messages: list[str], rev: str | None, shallow: bool, + depth: int | None, tmp_path: pathlib.Path, monkeypatch: MonkeyPatch, caplog: pytest.LogCaptureFixture, @@ -263,6 +306,7 @@ def test_add_repo( dry_run=dry_run, rev=rev, shallow=shallow, + depth=depth, ) # Check log messages diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index 210f707b..8456be8f 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -48,6 +48,7 @@ class DiscoverFixture(t.NamedTuple): preexisting_yaml: str | None pin: str | None = None shallow: bool = False + depth: int | None = None DISCOVER_FIXTURES: list[DiscoverFixture] = [ @@ -238,6 +239,24 @@ class DiscoverFixture(t.NamedTuple): preexisting_yaml=None, shallow=True, ), + DiscoverFixture( + test_id="depth-forced", + repos_to_create=[ + ("repo1", "git+https://github.com/user/repo1.git"), + ], + recursive=False, + workspace_override=None, + dry_run=False, + yes=True, + expected_repo_count=1, + config_relpath=".vcspull.yaml", + preexisting_config=None, + user_input=None, + expected_workspace_labels={"~/code/"}, + merge_duplicates=True, + preexisting_yaml=None, + depth=50, + ), ] @@ -398,6 +417,7 @@ def test_discover_repos( preexisting_yaml: str | None, pin: str | None, shallow: bool, + depth: int | None, tmp_path: pathlib.Path, monkeypatch: MonkeyPatch, caplog: pytest.LogCaptureFixture, @@ -451,6 +471,7 @@ def test_discover_repos( merge_duplicates=merge_duplicates, rev=pin, shallow=shallow, + depth=depth, ) if preexisting_yaml is not None or not merge_duplicates: @@ -500,9 +521,20 @@ def test_discover_repos( if isinstance(entry, dict) ] if pin is not None: - assert all(entry.get("rev") == pin for entry in persisted_entries) + assert all( + entry.get("options", {}).get("rev") == pin + for entry in persisted_entries + ) if shallow: - assert all(entry.get("shallow") is True for entry in persisted_entries) + assert all( + entry.get("options", {}).get("shallow") is True + for entry in persisted_entries + ) + if depth is not None: + assert all( + entry.get("options", {}).get("depth") == depth + for entry in persisted_entries + ) def test_discover_detects_shallow_clone( @@ -568,8 +600,66 @@ def test_discover_detects_shallow_clone( for name, entry in repos.items() if isinstance(entry, dict) } - assert "shallow" not in entries["fullrepo"] - assert entries["shallowrepo"]["shallow"] is True + assert "options" not in entries["fullrepo"] + assert entries["shallowrepo"]["options"]["shallow"] is True + + +def test_discover_detects_numeric_depth( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, +) -> None: + """Discover records ``options.depth: N`` for a depth>1 checkout.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + remote = tmp_path / "remote" + subprocess.run(["git", "init", "-q", str(remote)], check=True) + for message in ("first", "second", "third", "fourth"): + subprocess.run( + ["git", "-C", str(remote), "commit", "-q", "--allow-empty", "-m", message], + check=True, + ) + remote_url = f"file://{remote}" + + scan_dir = tmp_path / "code" + scan_dir.mkdir() + subprocess.run( + [ + "git", + "clone", + "-q", + # --no-local forces the standard transport so --depth is honored + # even when the source is on the same filesystem. + "--no-local", + "--depth", + "3", + remote_url, + str(scan_dir / "windowrepo"), + ], + check=True, + ) + + config_file = tmp_path / ".vcspull.yaml" + discover_repos( + scan_dir_str=str(scan_dir), + config_file_path_str=str(config_file), + recursive=False, + workspace_root_override=None, + yes=True, + dry_run=False, + ) + + import yaml + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + entries = { + name: entry + for repos in config.values() + if isinstance(repos, dict) + for name, entry in repos.items() + if isinstance(entry, dict) + } + assert entries["windowrepo"]["options"]["depth"] == 3 @pytest.mark.parametrize( diff --git a/tests/test_cli.py b/tests/test_cli.py index 90a558bc..1847463a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2299,7 +2299,7 @@ def test_sync_human_output_redacts_repo_paths( monkeypatch.setattr( sync_module, "load_configs", - lambda _paths: [repo_config], + lambda _paths, **_kwargs: [repo_config], ) monkeypatch.setattr( sync_module, diff --git a/tests/test_sync.py b/tests/test_sync.py index 27bdc67e..08bbbbdf 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import subprocess import textwrap import typing as t @@ -22,7 +23,13 @@ from vcspull._internal.config_reader import ConfigReader from vcspull.cli.sync import sync, update_repo -from vcspull.config import detect_git_shallow, extract_repos, filter_repos, load_configs +from vcspull.config import ( + detect_git_depth, + detect_git_shallow, + extract_repos, + filter_repos, + load_configs, +) from vcspull.validator import is_valid_config from .helpers import write_config @@ -500,3 +507,73 @@ def test_update_repo_git_rev( result = update_repo(repo_dict) assert isinstance(result, GitSync) assert result.get_revision() == tag_sha + + +def test_update_repo_git_depth( + tmp_path: pathlib.Path, + create_git_remote_repo: CreateRepoFn, +) -> None: + """A ``depth`` config entry clones with ``--depth N`` on sync.""" + dummy_repo = create_git_remote_repo( + remote_repo_post_init=git_remote_repo_single_commit_post_init, + ) + # A --depth N clone is only meaningfully shallow when the remote carries + # more history than the requested depth, so add commits past depth 2. + for message in ("second", "third", "fourth"): + subprocess.run( + [ + "git", + "-C", + str(dummy_repo), + "commit", + "-q", + "--allow-empty", + "-m", + message, + ], + check=True, + ) + + repo_dict: ConfigDict = { + "vcs": "git", + "name": "depthclone", + "path": tmp_path / "checkout" / "depthclone", + "url": f"git+file://{dummy_repo}", + "workspace_root": str(tmp_path / "checkout/"), + "depth": 2, + } + + result = update_repo(repo_dict) + assert isinstance(result, GitSync) + assert detect_git_depth(result.path) == 2 + + +def test_load_configs_warns_on_legacy_options( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """load_configs(warn_legacy_options=True) warns on top-level sync keys.""" + monkeypatch.setenv("HOME", str(tmp_path)) + config_file = write_config( + tmp_path / ".vcspull.yaml", + textwrap.dedent( + """\ + ~/code/: + flask: + repo: git+https://github.com/pallets/flask.git + shallow: true + """, + ), + ) + + with caplog.at_level(logging.WARNING, logger="vcspull.config"): + load_configs([config_file], warn_legacy_options=True) + + legacy_records = [ + record for record in caplog.records if hasattr(record, "vcspull_config_path") + ] + assert len(legacy_records) == 1 + assert legacy_records[0].levelno == logging.WARNING + assert "vcspull migrate" in legacy_records[0].getMessage() + assert legacy_records[0].vcspull_config_path == str(config_file) From 35f132cbda9090f6962c1c75439d13d63fc0ac6b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 12:29:49 -0500 Subject: [PATCH 04/11] cli(migrate) Add subcommand to relocate sync keys under options: why: Moving rev/shallow/depth under options: deprecates the v1.61.0 top-level keys. Users need a one-shot, idempotent rewrite of existing configs rather than hand-editing, and sync only warns about the old form. what: - Add `vcspull migrate` (mirrors fmt: -f/--file, --write, --all), reusing config.migrate_repo_entry; dry-run previews, --write rewrites - Register the subparser and dispatch arm in the CLI - Extend the CLI logger-names expectation for the new module - tests: migrate_config relocation matrix, write/dry-run, idempotency, and an end-to-end CLI run --- src/vcspull/cli/__init__.py | 39 +++++ src/vcspull/cli/migrate.py | 316 ++++++++++++++++++++++++++++++++++++ tests/cli/test_migrate.py | 189 +++++++++++++++++++++ tests/test_log.py | 1 + 4 files changed, 545 insertions(+) create mode 100644 src/vcspull/cli/migrate.py create mode 100644 tests/cli/test_migrate.py diff --git a/src/vcspull/cli/__init__.py b/src/vcspull/cli/__init__.py index ecc432c6..30f32452 100644 --- a/src/vcspull/cli/__init__.py +++ b/src/vcspull/cli/__init__.py @@ -19,6 +19,7 @@ from .fmt import create_fmt_subparser, format_config_file from .import_cmd import create_import_subparser from .list import create_list_subparser, list_repos +from .migrate import create_migrate_subparser, migrate_config_file from .search import create_search_subparser, search_repos from .status import create_status_subparser, status_repos from .sync import create_sync_subparser, sync @@ -244,6 +245,27 @@ def build_description( ), ) +MIGRATE_DESCRIPTION = build_description( + """ + Migrate configuration files to the options: form. + + Relocates per-repository rev/shallow/depth keys from the entry root into + the options: block. Without --write it previews changes; with --write it + rewrites the file(s). + """, + ( + ( + None, + [ + "vcspull migrate", + "vcspull migrate -f ./myrepos.yaml", + "vcspull migrate --write", + "vcspull migrate --all --write", + ], + ), + ), +) + IMPORT_DESCRIPTION = build_description( """ Import repositories from remote services. @@ -390,6 +412,15 @@ def create_parser( ) create_fmt_subparser(fmt_parser) + # Migrate command + migrate_parser = subparsers.add_parser( + "migrate", + help="migrate configuration files to the options: form", + formatter_class=VcspullHelpFormatter, + description=MIGRATE_DESCRIPTION, + ) + create_migrate_subparser(migrate_parser) + # Import command import_parser = subparsers.add_parser( "import", @@ -418,6 +449,7 @@ def create_parser( add_parser, discover_parser, fmt_parser, + migrate_parser, import_parser, worktree_parser, ) @@ -435,6 +467,7 @@ def cli(_args: list[str] | None = None) -> None: add_parser, discover_parser, _fmt_parser, + _migrate_parser, _import_parser, _worktree_parser, ) = subparsers @@ -549,6 +582,12 @@ def cli(_args: list[str] | None = None) -> None: args.all, merge_roots=args.merge_roots, ) + elif args.subparser_name == "migrate": + migrate_config_file( + args.config, + args.write, + args.all, + ) elif args.subparser_name == "import": handler = getattr(args, "import_handler", None) if handler is None: diff --git a/src/vcspull/cli/migrate.py b/src/vcspull/cli/migrate.py new file mode 100644 index 00000000..73b19528 --- /dev/null +++ b/src/vcspull/cli/migrate.py @@ -0,0 +1,316 @@ +"""Migrate vcspull configuration files to the ``options:`` form.""" + +from __future__ import annotations + +import argparse +import copy +import logging +import pathlib +import traceback +import typing as t + +from colorama import Fore, Style + +from vcspull._internal.config_reader import DuplicateAwareConfigReader +from vcspull._internal.private_path import PrivatePath +from vcspull.config import ( + LEGACY_REPO_OPTION_KEYS, + find_config_files, + find_home_config_files, + migrate_repo_entry, + normalize_config_file_path, + save_config, +) + +log = logging.getLogger(__name__) + + +def create_migrate_subparser(parser: argparse.ArgumentParser) -> None: + """Create ``vcspull migrate`` argument subparser.""" + parser.add_argument( + "-f", + "--file", + dest="config", + metavar="FILE", + help="path to config file (default: .vcspull.yaml or ~/.vcspull.yaml)", + ) + parser.add_argument( + "--write", + "-w", + action="store_true", + help="Write migrated configuration back to file", + ) + parser.add_argument( + "--all", + action="store_true", + help="Migrate all discovered config files (home, config dir, current dir)", + ) + + +def migrate_config(config_data: dict[str, t.Any]) -> tuple[dict[str, t.Any], int]: + """Relocate legacy top-level sync keys under ``options:`` for every entry. + + Parameters + ---------- + config_data : dict + Raw configuration data (workspace root → repo name → entry). + + Returns + ------- + tuple[dict, int] + The migrated configuration and the number of entries rewritten. + + Examples + -------- + >>> migrate_config( + ... {"~/code/": {"flask": {"repo": "git+x", "shallow": True}}} + ... ) + ({'~/code/': {'flask': {'repo': 'git+x', 'options': {'shallow': True}}}}, 1) + + An already-migrated config is returned unchanged: + + >>> migrate_config( + ... {"~/code/": {"flask": {"repo": "git+x", "options": {"shallow": True}}}} + ... ) + ({'~/code/': {'flask': {'repo': 'git+x', 'options': {'shallow': True}}}}, 0) + """ + migrated: dict[str, t.Any] = copy.deepcopy(config_data) + change_count = 0 + + for repos in migrated.values(): + if not isinstance(repos, dict): + continue + for repo_name, entry in repos.items(): + changed, new_entry = migrate_repo_entry(entry) + if changed: + repos[repo_name] = new_entry + change_count += 1 + + return migrated, change_count + + +def migrate_single_config(config_file_path: pathlib.Path, write: bool) -> bool: + """Migrate a single vcspull configuration file. + + Parameters + ---------- + config_file_path : pathlib.Path + Path to config file. + write : bool + Whether to write changes back to file. + + Returns + ------- + bool + ``True`` if the file was processed successfully, ``False`` otherwise. + """ + display_config_path = str(PrivatePath(config_file_path)) + + if not config_file_path.exists(): + log.error( + "%s✗%s Config file %s%s%s not found.", + Fore.RED, + Style.RESET_ALL, + Fore.BLUE, + display_config_path, + Style.RESET_ALL, + ) + return False + + try: + raw_config, _duplicate_root_occurrences, _top_level_items = ( + DuplicateAwareConfigReader.load_with_duplicates(config_file_path) + ) + except TypeError: + log.exception( + "Config file %s is not a mapping", + PrivatePath(config_file_path), + ) + return False + except Exception: + log.exception( + "Error loading config from %s", + PrivatePath(config_file_path), + ) + if log.isEnabledFor(logging.DEBUG): + traceback.print_exc() + return False + + migrated_config, change_count = migrate_config(raw_config) + + if change_count == 0: + log.info( + "%s✓%s %s%s%s already nests rev/shallow/depth under options:.", + Fore.GREEN, + Style.RESET_ALL, + Fore.BLUE, + display_config_path, + Style.RESET_ALL, + ) + return True + + log.info( + "%si%s Migrating %s%d%s %s in %s%s%s", + Fore.CYAN, + Style.RESET_ALL, + Fore.YELLOW, + change_count, + Style.RESET_ALL, + "entry" if change_count == 1 else "entries", + Fore.BLUE, + display_config_path, + Style.RESET_ALL, + ) + + moved = "/".join(LEGACY_REPO_OPTION_KEYS) + for workspace_label, repos in migrated_config.items(): + if not isinstance(repos, dict): + continue + original = raw_config.get(workspace_label) + for repo_name, entry in repos.items(): + previous = original.get(repo_name) if isinstance(original, dict) else None + if entry != previous: + log.info( + " %s•%s %s%s%s: moved %s under options:", + Fore.BLUE, + Style.RESET_ALL, + Fore.CYAN, + repo_name, + Style.RESET_ALL, + moved, + ) + + if write: + try: + save_config(config_file_path, migrated_config) + log.info( + "%s✓%s Successfully migrated %s%s%s", + Fore.GREEN, + Style.RESET_ALL, + Fore.BLUE, + display_config_path, + Style.RESET_ALL, + ) + except Exception: + log.exception( + "Error saving migrated config to %s", + PrivatePath(config_file_path), + ) + if log.isEnabledFor(logging.DEBUG): + traceback.print_exc() + return False + else: + log.info( + "\n%s→%s Run with %s--write%s to apply these changes.", + Fore.YELLOW, + Style.RESET_ALL, + Fore.CYAN, + Style.RESET_ALL, + ) + + return True + + +def migrate_config_file( + config_file_path_str: str | None, + write: bool, + migrate_all: bool = False, +) -> None: + """Migrate vcspull configuration file(s) to the ``options:`` form. + + Parameters + ---------- + config_file_path_str : str | None + Path to config file, or None to use the default. + write : bool + Whether to write changes back to file. + migrate_all : bool + If True, migrate all discovered config files. + """ + if migrate_all: + config_files = find_config_files(include_home=True) + + local_yaml = pathlib.Path.cwd() / ".vcspull.yaml" + if local_yaml.exists() and local_yaml not in config_files: + config_files.append(local_yaml) + + local_json = pathlib.Path.cwd() / ".vcspull.json" + if local_json.exists() and local_json not in config_files: + config_files.append(local_json) + + if not config_files: + log.error( + "%s✗%s No configuration files found.", + Fore.RED, + Style.RESET_ALL, + ) + return + + log.info( + "%si%s Found %s%d%s configuration %s to check:", + Fore.CYAN, + Style.RESET_ALL, + Fore.YELLOW, + len(config_files), + Style.RESET_ALL, + "file" if len(config_files) == 1 else "files", + ) + for config_file in config_files: + log.info( + " %s•%s %s%s%s", + Fore.BLUE, + Style.RESET_ALL, + Fore.CYAN, + str(PrivatePath(config_file)), + Style.RESET_ALL, + ) + log.info("") + + success_count = 0 + for config_file in config_files: + if migrate_single_config(config_file, write): + success_count += 1 + + if success_count == len(config_files): + log.info( + "\n%s✓%s All %d configuration files processed successfully.", + Fore.GREEN, + Style.RESET_ALL, + len(config_files), + ) + else: + log.info( + "\n%si%s Processed %d/%d configuration files successfully.", + Fore.CYAN, + Style.RESET_ALL, + success_count, + len(config_files), + ) + return + + if config_file_path_str: + config_file_path = normalize_config_file_path( + pathlib.Path(config_file_path_str) + ) + else: + home_configs = find_home_config_files(filetype=["yaml"]) + if not home_configs: + local_config = pathlib.Path.cwd() / ".vcspull.yaml" + if local_config.exists(): + config_file_path = local_config + else: + log.error( + "%s✗%s No configuration file found. Create .vcspull.yaml first.", + Fore.RED, + Style.RESET_ALL, + ) + return + 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] + + migrate_single_config(config_file_path, write) diff --git a/tests/cli/test_migrate.py b/tests/cli/test_migrate.py new file mode 100644 index 00000000..7b7c902a --- /dev/null +++ b/tests/cli/test_migrate.py @@ -0,0 +1,189 @@ +"""Tests for vcspull migrate command.""" + +from __future__ import annotations + +import logging +import typing as t + +import pytest +import yaml + +from vcspull.cli import cli +from vcspull.cli.migrate import migrate_config, migrate_config_file +from vcspull.config import save_config_yaml + +if t.TYPE_CHECKING: + import pathlib + + from _pytest.monkeypatch import MonkeyPatch + + +class MigrateConfigFixture(t.NamedTuple): + """Fixture for migrate_config relocation cases.""" + + test_id: str + raw_config: dict[str, t.Any] + expected_config: dict[str, t.Any] + expected_changes: int + + +MIGRATE_CONFIG_FIXTURES: list[MigrateConfigFixture] = [ + MigrateConfigFixture( + test_id="legacy-shallow", + raw_config={"~/code/": {"flask": {"repo": "git+x", "shallow": True}}}, + expected_config={ + "~/code/": {"flask": {"repo": "git+x", "options": {"shallow": True}}}, + }, + expected_changes=1, + ), + MigrateConfigFixture( + test_id="already-options", + raw_config={ + "~/code/": {"flask": {"repo": "git+x", "options": {"shallow": True}}}, + }, + expected_config={ + "~/code/": {"flask": {"repo": "git+x", "options": {"shallow": True}}}, + }, + expected_changes=0, + ), + MigrateConfigFixture( + test_id="depth-wins", + raw_config={ + "~/code/": {"flask": {"repo": "git+x", "shallow": True, "depth": 5}} + }, + expected_config={ + "~/code/": {"flask": {"repo": "git+x", "options": {"depth": 5}}}, + }, + expected_changes=1, + ), + MigrateConfigFixture( + test_id="preserves-pin", + raw_config={ + "~/code/": { + "flask": {"repo": "git+x", "rev": "v1", "options": {"pin": True}}, + }, + }, + expected_config={ + "~/code/": { + "flask": {"repo": "git+x", "options": {"pin": True, "rev": "v1"}}, + }, + }, + expected_changes=1, + ), + MigrateConfigFixture( + test_id="string-entry-untouched", + raw_config={"~/code/": {"flask": "git+x"}}, + expected_config={"~/code/": {"flask": "git+x"}}, + expected_changes=0, + ), +] + + +@pytest.mark.parametrize( + list(MigrateConfigFixture._fields), + MIGRATE_CONFIG_FIXTURES, + ids=[f.test_id for f in MIGRATE_CONFIG_FIXTURES], +) +def test_migrate_config( + test_id: str, + raw_config: dict[str, t.Any], + expected_config: dict[str, t.Any], + expected_changes: int, +) -> None: + """migrate_config relocates legacy keys and counts rewritten entries.""" + migrated, change_count = migrate_config(raw_config) + assert migrated == expected_config + assert change_count == expected_changes + + +def test_migrate_config_file_write( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, +) -> None: + """Migrate --write rewrites legacy entries into the options: form.""" + monkeypatch.setenv("HOME", str(tmp_path)) + config_file = tmp_path / ".vcspull.yaml" + save_config_yaml( + config_file, + { + "~/code/": { + "flask": {"repo": "git+https://example.com/flask.git", "rev": "v1"}, + "django": { + "repo": "git+https://example.com/django.git", + "shallow": True, + "depth": 5, + }, + }, + }, + ) + + migrate_config_file(str(config_file), write=True) + + result = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert result["~/code/"]["flask"] == { + "repo": "git+https://example.com/flask.git", + "options": {"rev": "v1"}, + } + assert result["~/code/"]["django"] == { + "repo": "git+https://example.com/django.git", + "options": {"depth": 5}, + } + + +def test_migrate_config_file_dry_run( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, +) -> None: + """Migrate without --write leaves the file untouched.""" + monkeypatch.setenv("HOME", str(tmp_path)) + config_file = tmp_path / ".vcspull.yaml" + save_config_yaml( + config_file, + {"~/code/": {"flask": {"repo": "git+x", "shallow": True}}}, + ) + before = config_file.read_text(encoding="utf-8") + + migrate_config_file(str(config_file), write=False) + + assert config_file.read_text(encoding="utf-8") == before + + +def test_migrate_idempotent( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A second migrate --write run makes no changes.""" + monkeypatch.setenv("HOME", str(tmp_path)) + config_file = tmp_path / ".vcspull.yaml" + save_config_yaml( + config_file, + {"~/code/": {"flask": {"repo": "git+x", "shallow": True}}}, + ) + + migrate_config_file(str(config_file), write=True) + after_first = config_file.read_text(encoding="utf-8") + + with caplog.at_level(logging.INFO, logger="vcspull.cli.migrate"): + migrate_config_file(str(config_file), write=True) + + assert config_file.read_text(encoding="utf-8") == after_first + assert any("already nests" in record.getMessage() for record in caplog.records) + + +def test_migrate_cli_end_to_end( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, +) -> None: + """`vcspull migrate -f FILE --write` rewrites the file.""" + monkeypatch.setenv("HOME", str(tmp_path)) + config_file = tmp_path / ".vcspull.yaml" + save_config_yaml( + config_file, + {"~/code/": {"flask": {"repo": "git+x", "shallow": True}}}, + ) + + cli(["migrate", "-f", str(config_file), "--write"]) + + result = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert result["~/code/"]["flask"] == {"repo": "git+x", "options": {"shallow": True}} diff --git a/tests/test_log.py b/tests/test_log.py index 8fb7acd6..019e5076 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -443,6 +443,7 @@ def test_get_cli_logger_names_includes_base() -> None: "vcspull.cli.import_cmd.github", "vcspull.cli.import_cmd.gitlab", "vcspull.cli.list", + "vcspull.cli.migrate", "vcspull.cli.search", "vcspull.cli.status", "vcspull.cli.sync", From e18892153826641b00675f268778bf8e9f4443ca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 12:32:58 -0500 Subject: [PATCH 05/11] docs(configuration,cli[migrate]) Document options: sync keys and depth why: The per-repo sync keys now live under options:, depth: N is new, and the top-level form is deprecated. Users need the canonical shape, the precedence rules, and the migration path documented. what: - configuration: group rev/shallow/depth under an "options:" section, add a clone-depth section, and document migrating off the top-level form via vcspull migrate - cli: add the vcspull migrate page (grid, toctree, see-also) and its API automodule page --- docs/cli/index.md | 9 +++- docs/cli/migrate.md | 76 +++++++++++++++++++++++++++++++ docs/configuration/index.md | 61 ++++++++++++++++++++----- docs/internals/api/cli/index.md | 1 + docs/internals/api/cli/migrate.md | 8 ++++ 5 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 docs/cli/migrate.md create mode 100644 docs/internals/api/cli/migrate.md diff --git a/docs/cli/index.md b/docs/cli/index.md index d3e7e9f5..a8d5d4bb 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -49,6 +49,12 @@ Scan directories for existing repos. Normalize and format config files. ::: +:::{grid-item-card} vcspull migrate +:link: migrate +:link-type: doc +Move rev/shallow/depth under options:. +::: + :::{grid-item-card} vcspull import :link: import/index :link-type: doc @@ -82,6 +88,7 @@ search status worktree/index fmt +migrate ``` ```{toctree} @@ -105,5 +112,5 @@ completion :nosubcommands: subparser_name : @replace - See :ref:`cli-sync`, :ref:`cli-add`, :ref:`cli-import`, :ref:`cli-discover`, :ref:`cli-list`, :ref:`cli-search`, :ref:`cli-status`, :ref:`cli-worktree`, :ref:`cli-fmt` + See :ref:`cli-sync`, :ref:`cli-add`, :ref:`cli-import`, :ref:`cli-discover`, :ref:`cli-list`, :ref:`cli-search`, :ref:`cli-status`, :ref:`cli-worktree`, :ref:`cli-fmt`, :ref:`cli-migrate` ``` diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md new file mode 100644 index 00000000..971ee6db --- /dev/null +++ b/docs/cli/migrate.md @@ -0,0 +1,76 @@ +(cli-migrate)= + +# vcspull migrate + +`vcspull migrate` rewrites configuration files to the current schema, moving the +per-repository `rev`, `shallow`, and `depth` keys from the entry root into the +`options:` block. By default it prints the proposed changes; apply them in place +with `--write`. + +These keys shipped at the entry root in vcspull v1.61.0. They are still read, +but {ref}`cli-sync` warns when it encounters them. Migrating clears the warning +and keeps configs on the supported shape. + +## Command + +```{eval-rst} +.. argparse:: + :module: vcspull.cli + :func: create_parser + :prog: vcspull + :path: migrate +``` + +## What gets migrated + +For each repository entry, a top-level `rev`, `shallow`, or `depth` key is moved +under `options:`. A value already present under `options:` wins, and `depth` +takes precedence over `shallow`. Entries already on the `options:` form, string +shorthands, and unrelated keys are left untouched. + +Given: + +```yaml +~/code/: + flask: + repo: git+https://github.com/pallets/flask.git + rev: v3.0.0 + shallow: true +``` + +`vcspull migrate --write` produces: + +```yaml +~/code/: + flask: + repo: git+https://github.com/pallets/flask.git + options: + rev: v3.0.0 + shallow: true +``` + +## Writing changes + +Preview the rewrite first: + +```console +$ vcspull migrate --file ~/.vcspull.yaml +``` + +Then add `--write` to persist it: + +```console +$ vcspull migrate \ + --file ~/.vcspull.yaml \ + --write +``` + +Use `--all` to iterate over the default search locations: the current working +directory, `~/.vcspull.*`, and the XDG configuration directory. + +```console +$ vcspull migrate --all --write +``` + +Migration is idempotent—running it again on an already-migrated file makes no +changes. diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 343aa09b..d3f3a642 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -119,11 +119,18 @@ Optional fields: See {ref}`cli-worktree` for full command documentation. -## Revision pinning +## Sync options -A `rev:` key pins a repository to a commit, tag, or branch, which {ref}`cli-sync` -checks out. This lets a config capture a reproducible snapshot instead of -tracking the branch tip. It is distinct from `options.pin` (see +Per-repository sync behavior lives under an `options:` block alongside the +`repo` URL. The keys below tune how {ref}`cli-sync` clones and updates a +checkout. Mutation policy such as `pin` lives in the same block (see +{ref}`config-pin`). + +### Revision pinning + +`options.rev` pins a repository to a commit, tag, or branch, which +{ref}`cli-sync` checks out. This lets a config capture a reproducible snapshot +instead of tracking the branch tip. It is distinct from `options.pin` (see {ref}`config-pin`), which guards the config entry from being overwritten rather than pinning a git ref. @@ -131,16 +138,17 @@ than pinning a git ref. ~/code/: flask: repo: git+https://github.com/pallets/flask.git - rev: v3.0.0 + options: + rev: v3.0.0 ``` `vcspull add --pin ` and `vcspull discover --pin ` record this key when importing an existing checkout. See {ref}`cli-add` and {ref}`cli-discover`. -## Shallow clones +### Shallow clones -A `shallow: true` key makes {ref}`cli-sync` clone the repository with +`options.shallow: true` makes {ref}`cli-sync` clone the repository with `--depth 1`, trading git history for disk and time—useful for workspaces with many repositories. @@ -148,12 +156,43 @@ many repositories. ~/code/: flask: repo: git+https://github.com/pallets/flask.git - shallow: true + options: + shallow: true ``` -`vcspull add` and `vcspull discover` detect an existing shallow checkout -automatically and record `shallow: true`; the `--shallow` flag forces it on even -for a full checkout. +`vcspull add` and `vcspull discover` detect an existing depth-1 checkout +automatically and record `options.shallow: true`; the `--shallow` flag forces it +on even for a full checkout. + +### Clone depth + +`options.depth: N` keeps a small window of history by cloning with `--depth N`. +Reach for it when `shallow: true` (depth 1) is too little—for example, a handful +of recent commits for `git log` or `git bisect`. + +```yaml +~/code/: + django: + repo: git+https://github.com/django/django.git + options: + depth: 50 +``` + +`vcspull add --depth N` and `vcspull discover --depth N` record this +key. When importing an existing shallow checkout, both detect its depth: a +depth-1 checkout records `options.shallow: true` and a deeper window records +`options.depth: N`. `depth` takes precedence over `shallow` when both are set. + +### Migrating from the top-level form + +vcspull v1.61.0 accepted `rev:` and `shallow:` at the repository entry root. +Those keys still work but are deprecated in favor of the `options:` block, and +{ref}`cli-sync` warns when it reads them. Run {ref}`cli-migrate` to rewrite +existing configs in place: + +```console +$ vcspull migrate --write +``` (config-pin)= diff --git a/docs/internals/api/cli/index.md b/docs/internals/api/cli/index.md index a422cc69..5427d727 100644 --- a/docs/internals/api/cli/index.md +++ b/docs/internals/api/cli/index.md @@ -17,6 +17,7 @@ search status worktree fmt +migrate ``` ## vcspull CLI - `vcspull.cli` diff --git a/docs/internals/api/cli/migrate.md b/docs/internals/api/cli/migrate.md new file mode 100644 index 00000000..de232d09 --- /dev/null +++ b/docs/internals/api/cli/migrate.md @@ -0,0 +1,8 @@ +# vcspull migrate - `vcspull.cli.migrate` + +```{eval-rst} +.. automodule:: vcspull.cli.migrate + :members: + :show-inheritance: + :undoc-members: +``` From 8bfb1fec40d7677819b792322432984af6b29c03 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 12:35:34 -0500 Subject: [PATCH 06/11] docs(CHANGES) Add numeric depth and options: relocation entries why: Record the user-facing add/discover/sync/migrate changes for the unreleased 1.61 line. what: - Add "Numeric clone depth" (#552) deliverable - Add "Migrate configs to the options: form" (#552) deliverable - Note the libvcs>=0.42.0 floor and the deprecation of top-level rev/shallow/depth with a before/after migration path --- CHANGES | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/CHANGES b/CHANGES index 3bdf94a3..af487d7a 100644 --- a/CHANGES +++ b/CHANGES @@ -37,6 +37,53 @@ $ uvx --from 'vcspull' --prerelease allow vcspull _Notes on upcoming releases will be added here_ +### Breaking changes + +#### Per-repository sync keys now live under `options:` (#552) + +The per-repository `rev`, `shallow`, and new `depth` keys are written under an +`options:` block instead of the entry root. The top-level form that shipped in +v1.61.0 is still read, but {ref}`cli-sync` warns when it encounters it. Run +{ref}`cli-migrate` to rewrite existing configs: + +```yaml +# Before +~/code/: + flask: + repo: git+https://github.com/pallets/flask.git + shallow: true + +# After +~/code/: + flask: + repo: git+https://github.com/pallets/flask.git + options: + shallow: true +``` + +### Dependencies + +#### Minimum `libvcs>=0.42.0` (was `>=0.41.0`) + +libvcs 0.42.0 teaches its git backend an arbitrary clone depth, which the new +`depth: N` key relies on. + +### What's new + +#### Numeric clone depth (#552) + +A repository can keep a small window of history with `options.depth: N` (clone +with `--depth N`) instead of the all-or-nothing `shallow: true`. {ref}`cli-add` +and {ref}`cli-discover` accept `--depth N` and detect an existing checkout's +depth automatically—recording `shallow: true` for a depth-1 checkout and +`depth: N` for a deeper window. See {ref}`configuration`. + +#### Migrate configs to the `options:` form (#552) + +{ref}`cli-migrate` rewrites configuration files that still carry top-level +`rev`/`shallow`/`depth`, moving them under `options:`. It previews changes by +default and applies them with `--write`. + ## vcspull v1.61.0 (2026-05-30) vcspull v1.61.0 lets `add` and `discover` capture per-repository git state when From 2eeabed4ae1aca8ca5a848ed6ff45f70669440c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 13:59:48 -0500 Subject: [PATCH 07/11] docs(MIGRATION) Note the options: relocation and vcspull migrate why: Users on v1.61.0 wrote top-level rev/shallow keys; they need a migration note telling them the form changed, that the old keys still load but warn, and how to rewrite configs. what: - Add a 'Next release' entry covering the rev/shallow/depth move under options:, with before/after YAML and the vcspull migrate --write step - Mention depth: N as the new numeric clone-depth key --- MIGRATION | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/MIGRATION b/MIGRATION index 1e07b02c..24b4fe91 100644 --- a/MIGRATION +++ b/MIGRATION @@ -21,7 +21,41 @@ well. ## Next release -_Notes on the upcoming release will be added here_ +### Per-repository sync keys moved under `options:` + +_via #552_ + +The per-repository `rev`, `shallow`, and the new `depth` keys are now written +under an `options:` block instead of the repository entry root. The top-level +form still loads, but `vcspull sync` now warns when it reads it and points you +here. Rewrite your configs in place: + +```console +$ vcspull migrate --write +``` + +Before: + +```yaml +~/code/: + flask: + repo: git+https://github.com/pallets/flask.git + shallow: true +``` + +After: + +```yaml +~/code/: + flask: + repo: git+https://github.com/pallets/flask.git + options: + shallow: true +``` + +`depth: N` is new — record a numeric clone depth (`git clone --depth N`) when a +boolean `shallow` (depth 1) is too little. See {ref}`configuration` and +{ref}`cli-migrate`. From e90ed1113e97f835c71be3928f7d2c974b7edcf7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 14:01:05 -0500 Subject: [PATCH 08/11] docs(configuration,cli[migrate]) Cross-link the migration surfaces why: The migration story spans three pages (config schema, the migrate command, and the migration notes); readers landing on one had no path to the others. what: - Link the configuration migration subsection to the migration notes - Add a 'See also' on the migrate command page pointing to the config schema and the migration notes --- docs/cli/migrate.md | 5 +++++ docs/configuration/index.md | 3 +++ 2 files changed, 8 insertions(+) diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index 971ee6db..62cf18b2 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -74,3 +74,8 @@ $ vcspull migrate --all --write Migration is idempotent—running it again on an already-migrated file makes no changes. + +## See also + +- {ref}`configuration` — the `options:` block and its sync-tuning keys. +- {ref}`migration` — the deprecation note for the top-level form. diff --git a/docs/configuration/index.md b/docs/configuration/index.md index d3f3a642..dd68876f 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -194,6 +194,9 @@ existing configs in place: $ vcspull migrate --write ``` +See {ref}`migration` for the deprecation note and {ref}`cli-migrate` for the +command reference. + (config-pin)= ## Repository pinning From 9f1b903579f4cab58014265f8b766fa0ac84474b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 14:03:14 -0500 Subject: [PATCH 09/11] test(sync[legacy-options]) Parametrize the deprecation-warning coverage why: Only a single shape (top-level shallow) proved the legacy-format warning; the rev/depth/combo/multi paths and the negative cases (canonical options:, string shorthand) were unverified. what: - Replace the single test with a NamedTuple + test_id matrix over load_configs(warn_legacy_options=True) - Cover rev/shallow/depth/combo and multi-entry (assert affected count parsed from the message), plus canonical and string entries that must NOT warn - Assert on caplog.records schema (level, vcspull_config_path, message) --- tests/test_sync.py | 118 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 15 deletions(-) diff --git a/tests/test_sync.py b/tests/test_sync.py index 08bbbbdf..a85bd792 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -29,6 +29,7 @@ extract_repos, filter_repos, load_configs, + save_config_yaml, ) from vcspull.validator import is_valid_config @@ -548,24 +549,101 @@ def test_update_repo_git_depth( assert detect_git_depth(result.path) == 2 +class LegacyWarningFixture(t.NamedTuple): + """Fixture for the legacy top-level-keys deprecation warning.""" + + test_id: str + config: dict[str, t.Any] + expect_warning: bool + affected_count: int + + +_REPO = "git+https://github.com/pallets/flask.git" + +LEGACY_WARNING_FIXTURES: list[LegacyWarningFixture] = [ + LegacyWarningFixture( + test_id="legacy-rev", + config={"~/code/": {"flask": {"repo": _REPO, "rev": "v1"}}}, + expect_warning=True, + affected_count=1, + ), + LegacyWarningFixture( + test_id="legacy-shallow", + config={"~/code/": {"flask": {"repo": _REPO, "shallow": True}}}, + expect_warning=True, + affected_count=1, + ), + LegacyWarningFixture( + test_id="legacy-depth", + config={"~/code/": {"flask": {"repo": _REPO, "depth": 3}}}, + expect_warning=True, + affected_count=1, + ), + LegacyWarningFixture( + test_id="legacy-combo", + config={ + "~/code/": { + "flask": {"repo": _REPO, "rev": "v1", "shallow": True, "depth": 3} + } + }, + expect_warning=True, + affected_count=1, + ), + LegacyWarningFixture( + test_id="multiple-legacy", + config={ + "~/code/": { + "a": {"repo": _REPO, "rev": "v1"}, + "b": {"repo": _REPO, "shallow": True}, + }, + }, + expect_warning=True, + affected_count=2, + ), + LegacyWarningFixture( + test_id="mixed", + config={ + "~/code/": { + "a": {"repo": _REPO, "rev": "v1"}, + "b": {"repo": _REPO, "options": {"shallow": True}}, + }, + }, + expect_warning=True, + affected_count=1, + ), + LegacyWarningFixture( + test_id="canonical-options", + config={"~/code/": {"flask": {"repo": _REPO, "options": {"shallow": True}}}}, + expect_warning=False, + affected_count=0, + ), + LegacyWarningFixture( + test_id="string-shorthand", + config={"~/code/": {"flask": _REPO}}, + expect_warning=False, + affected_count=0, + ), +] + + +@pytest.mark.parametrize( + list(LegacyWarningFixture._fields), + LEGACY_WARNING_FIXTURES, + ids=[fixture.test_id for fixture in LEGACY_WARNING_FIXTURES], +) def test_load_configs_warns_on_legacy_options( + test_id: str, + config: dict[str, t.Any], + expect_warning: bool, + affected_count: int, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: - """load_configs(warn_legacy_options=True) warns on top-level sync keys.""" + """load_configs(warn_legacy_options=True) warns only on top-level sync keys.""" monkeypatch.setenv("HOME", str(tmp_path)) - config_file = write_config( - tmp_path / ".vcspull.yaml", - textwrap.dedent( - """\ - ~/code/: - flask: - repo: git+https://github.com/pallets/flask.git - shallow: true - """, - ), - ) + config_file = tmp_path / ".vcspull.yaml" + save_config_yaml(config_file, config) with caplog.at_level(logging.WARNING, logger="vcspull.config"): load_configs([config_file], warn_legacy_options=True) @@ -573,7 +651,17 @@ def test_load_configs_warns_on_legacy_options( legacy_records = [ record for record in caplog.records if hasattr(record, "vcspull_config_path") ] + + if not expect_warning: + assert legacy_records == [] + return + + # The warning is emitted once per file, naming every affected entry. assert len(legacy_records) == 1 - assert legacy_records[0].levelno == logging.WARNING - assert "vcspull migrate" in legacy_records[0].getMessage() - assert legacy_records[0].vcspull_config_path == str(config_file) + record = legacy_records[0] + assert record.levelno == logging.WARNING + assert "vcspull migrate" in record.getMessage() + assert record.vcspull_config_path == str(config_file) + + affected = record.getMessage().split("Affected: ", 1)[1] + assert len([entry for entry in affected.split(", ") if entry]) == affected_count From 902c002656f0bc969c9da54455ee626801473449 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 15:20:20 -0500 Subject: [PATCH 10/11] test(sync[legacy-options]) Assert on a structured count, not the warning text why: The deprecation-warning test counted affected entries by parsing the rendered log message ("Affected: ...".split), which couples the test to message formatting. The project standard is to assert on structured record attributes, not the message string. what: - Carry the affected-entry count as a scalar `vcspull_legacy_count` in the warning's `extra` (alongside `vcspull_config_path`) - Assert `record.vcspull_legacy_count == affected_count`; narrow the record filter on both extras so the access stays type-checked --- src/vcspull/config.py | 5 ++++- tests/test_sync.py | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/vcspull/config.py b/src/vcspull/config.py index 2e46280d..6697236d 100644 --- a/src/vcspull/config.py +++ b/src/vcspull/config.py @@ -599,7 +599,10 @@ def load_configs( "under 'options:' (run 'vcspull migrate'). Affected: %s", file, affected, - extra={"vcspull_config_path": str(file)}, + extra={ + "vcspull_config_path": str(file), + "vcspull_legacy_count": len(legacy_entries), + }, ) assert is_valid_config(config_content) diff --git a/tests/test_sync.py b/tests/test_sync.py index a85bd792..298c5481 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -649,7 +649,10 @@ def test_load_configs_warns_on_legacy_options( load_configs([config_file], warn_legacy_options=True) legacy_records = [ - record for record in caplog.records if hasattr(record, "vcspull_config_path") + record + for record in caplog.records + if hasattr(record, "vcspull_config_path") + and hasattr(record, "vcspull_legacy_count") ] if not expect_warning: @@ -662,6 +665,4 @@ def test_load_configs_warns_on_legacy_options( assert record.levelno == logging.WARNING assert "vcspull migrate" in record.getMessage() assert record.vcspull_config_path == str(config_file) - - affected = record.getMessage().split("Affected: ", 1)[1] - assert len([entry for entry in affected.split(", ") if entry]) == affected_count + assert record.vcspull_legacy_count == affected_count From 597104f848477371eae91e446cf252f1e232a7db Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 31 May 2026 15:21:18 -0500 Subject: [PATCH 11/11] docs(cli/sync[update_repo]) Correct the shallow/depth translation comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The comment omitted the actual reason for applying the keys post-construction (libvcs names shallow `git_shallow`, so it must be translated) and overstated "leaking git-only kwargs into the svn/hg sync constructors" — HgSync/SvnSync absorb unknown **kwargs harmlessly, so that was never the failure mode. what: - State the real rationale: translate shallow->git_shallow, apply both as attributes for git only, and let obtain() resolve precedence - Comment-only; no behavior change --- src/vcspull/cli/sync.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py index 55f45e1f..ec9a78bb 100644 --- a/src/vcspull/cli/sync.py +++ b/src/vcspull/cli/sync.py @@ -1927,11 +1927,11 @@ def update_repo( repo_dict["progress_callback"] = progress_callback or progress_cb - # vcspull surfaces options.shallow/options.depth as flat ``shallow``/ - # ``depth`` keys on the ConfigDict. libvcs names them ``git_shallow``/ - # ``depth``; translate and apply them as attributes after construction so - # they reach obtain() (depth wins over shallow) without leaking git-only - # kwargs into the svn/hg sync constructors. + # The ConfigDict carries options.shallow/options.depth as flat ``shallow``/ + # ``depth`` keys. libvcs's GitSync names the former ``git_shallow``, so + # translate it; apply both as attributes after construction and only for + # git. ``obtain()`` then resolves precedence (an explicit depth wins over + # ``git_shallow``). git_shallow = bool(repo_dict.pop("shallow", False)) git_depth = repo_dict.pop("depth", None)