Skip to content

Commit 0ade798

Browse files
committed
config(feat): Write canonical repository entries
why: Newly written entries must use the same layout as the loader. what: Write checkout targets and Git settings in their named blocks. Migrate entries touched by import and fmt while honoring command pins. Reject invalid writer settings and report migration errors before writes. Make local Git discovery explicit and isolate the home-config doctest.
1 parent 3f58bc5 commit 0ade798

12 files changed

Lines changed: 175 additions & 73 deletions

File tree

‎CHANGES‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ at the repository entry level.
6464
{ref}`cli-migrate` separates legacy settings into `working_copy`, the
6565
matching backend block, and entry-level pin policy in one pass. Unknown
6666
legacy options stop migration before it writes the configuration file.
67+
`add` and `discover` write the same layout. `import` and `fmt` migrate entries
68+
they rewrite and preserve entries pinned for that command.
6769
See {ref}`migration` for an upgrade example.
6870

6971
#### Validate shared checkout targets

‎MIGRATION‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ After:
6565
Existing canonical values take precedence. Unknown legacy `options` keys
6666
produce an error before the file is written.
6767

68+
`add` and `discover` write the canonical layout. `import` and `fmt` migrate
69+
entries they rewrite; entries pinned for that command stay unchanged. Use
70+
`migrate --write` to upgrade every entry, including pinned entries.
71+
6872
Loading normalizes the same legacy locations in memory and warns once per
6973
file. Unknown entry or backend fields now fail during loading; metadata
7074
belongs in `metadata`, not in a libvcs constructor argument. Sync constructs

‎src/vcspull/cli/add.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
save_config_yaml_with_items,
3535
workspace_root_label,
3636
)
37+
from vcspull.exc import VCSPullException
3738

3839
log = logging.getLogger(__name__)
3940

@@ -133,7 +134,7 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None:
133134
dest="shallow",
134135
action="store_true",
135136
help=(
136-
"Record 'options.shallow: true' (clone --depth 1 on sync). A "
137+
"Record 'git.depth: 1' (clone --depth 1 on sync). A "
137138
"shallow checkout is detected automatically; this forces it on."
138139
),
139140
)
@@ -143,7 +144,7 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None:
143144
type=int,
144145
metavar="N",
145146
help=(
146-
"Record 'options.depth: N' (clone --depth N on sync). Overrides "
147+
"Record 'git.depth: N' (clone --depth N on sync). Overrides "
147148
"--shallow. An existing shallow checkout's depth is detected "
148149
"automatically."
149150
),
@@ -270,7 +271,7 @@ class ParsedRepoUrl(t.NamedTuple):
270271
url : str
271272
The URL without any pip-style ``@rev``, suitable to record as ``repo``.
272273
rev : str | None
273-
Revision from a pip-style ``@rev``, to record as ``options.rev``.
274+
Revision from a pip-style ``@rev``, to record as ``working_copy.rev``.
274275
unparsed_rev : str | None
275276
Revision trailing a URL libvcs does not parse revisions for, which
276277
would otherwise stay in the recorded URL and fail to clone.
@@ -1049,11 +1050,11 @@ def add_repo(
10491050
dry_run : bool
10501051
If True, preview changes without writing
10511052
rev : str | None
1052-
Commit, tag, or branch to record as ``options.rev``.
1053+
Commit, tag, or branch to record as ``working_copy.rev``.
10531054
shallow : bool
1054-
If ``True``, record ``options.shallow: true`` for the repository.
1055+
If ``True``, record ``git.depth: 1`` for the repository.
10551056
depth : int | None
1056-
If set, record ``options.depth: N`` for the repository.
1057+
If set, record ``git.depth: N`` for the repository.
10571058
"""
10581059
# Determine config file
10591060
resolution = _resolve_config_file(config_file_path_str)
@@ -1123,7 +1124,11 @@ def add_repo(
11231124
preserve_cwd_label=explicit_dot,
11241125
)
11251126

1126-
new_repo_entry = build_repo_entry(url, rev=rev, shallow=shallow, depth=depth)
1127+
try:
1128+
new_repo_entry = build_repo_entry(url, rev=rev, shallow=shallow, depth=depth)
1129+
except VCSPullException as error:
1130+
log.error("%s: %s", name, error) # noqa: TRY400
1131+
return
11271132

11281133
def _ensure_workspace_label_for_merge(
11291134
config_data: dict[str, t.Any],

‎src/vcspull/cli/discover.py‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
save_config,
3030
workspace_root_label,
3131
)
32+
from vcspull.exc import VCSPullException
3233

3334
log = logging.getLogger(__name__)
3435

@@ -53,9 +54,9 @@ class _FoundRepo(t.NamedTuple):
5354
workspace_path : pathlib.Path
5455
Workspace root the entry is recorded under.
5556
shallow : bool
56-
Whether to record ``options.shallow: true``.
57+
Whether to record ``git.depth: 1``.
5758
depth : int | None
58-
Clone depth to record as ``options.depth``, or ``None`` for none.
59+
Clone depth to record as ``git.depth``, or ``None`` for none.
5960
"""
6061

6162
name: str
@@ -220,10 +221,12 @@ def get_git_origin_url(repo_path: pathlib.Path) -> str | None:
220221
text=True,
221222
check=True,
222223
)
223-
return result.stdout.strip()
224224
except (subprocess.CalledProcessError, FileNotFoundError) as e:
225225
log.debug("Could not get origin URL for %s: %s", repo_path, e)
226226
return None
227+
else:
228+
url = result.stdout.strip()
229+
return f"git+{url}" if url.startswith("file://") else url
227230

228231

229232
def create_discover_subparser(parser: argparse.ArgumentParser) -> None:
@@ -274,7 +277,7 @@ def create_discover_subparser(parser: argparse.ArgumentParser) -> None:
274277
dest="shallow",
275278
action="store_true",
276279
help=(
277-
"Record 'options.shallow: true' for discovered repositories. "
280+
"Record 'git.depth: 1' for discovered repositories. "
278281
"Shallow checkouts are detected automatically; this forces it on "
279282
"for all."
280283
),
@@ -285,7 +288,7 @@ def create_discover_subparser(parser: argparse.ArgumentParser) -> None:
285288
type=int,
286289
metavar="N",
287290
help=(
288-
"Record 'options.depth: N' for every discovered repository "
291+
"Record 'git.depth: N' for every discovered repository "
289292
"(clone --depth N on sync). Overrides --shallow."
290293
),
291294
)
@@ -382,13 +385,13 @@ def discover_repos(
382385
dry_run : bool
383386
If True, preview changes without writing
384387
rev : str | None
385-
Commit, tag, or branch to record as ``options.rev`` for every
388+
Commit, tag, or branch to record as ``working_copy.rev`` for every
386389
discovered repository.
387390
shallow : bool
388-
If ``True``, force ``options.shallow: true`` for every discovered
391+
If ``True``, force ``git.depth: 1`` for every discovered
389392
repository; otherwise clone depth is auto-detected per repository.
390393
depth : int | None
391-
If set, record ``options.depth: N`` for every discovered repository;
394+
If set, record ``git.depth: N`` for every discovered repository;
392395
overrides ``shallow``.
393396
"""
394397
if depth is not None and depth < 1:
@@ -869,12 +872,16 @@ def discover_repos(
869872
continue
870873

871874
if repo_name not in raw_config[workspace_label]:
872-
raw_config[workspace_label][repo_name] = build_repo_entry(
873-
repo_url,
874-
rev=rev,
875-
shallow=repo_shallow,
876-
depth=repo_depth,
877-
)
875+
try:
876+
raw_config[workspace_label][repo_name] = build_repo_entry(
877+
repo_url,
878+
rev=rev,
879+
shallow=repo_shallow,
880+
depth=repo_depth,
881+
)
882+
except VCSPullException as error:
883+
log.error("%s: %s", repo_name, error) # noqa: TRY400
884+
return
878885
log.info(
879886
"%s+%s Importing %s'%s'%s (%s%s%s) under '%s%s%s'.",
880887
Fore.GREEN,

‎src/vcspull/cli/fmt.py‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
find_home_config_files,
2020
is_pinned_for_op,
2121
merge_duplicate_workspace_roots,
22+
migrate_repo_entry,
2223
normalize_config_file_path,
2324
normalize_workspace_roots,
2425
save_config,
@@ -105,13 +106,11 @@ def normalize_repo_config(repo_data: t.Any) -> dict[str, t.Any]:
105106
# Convert compact format to verbose format
106107
return {"repo": repo_data}
107108
if isinstance(repo_data, dict):
108-
# If it has 'url' key but not 'repo', convert to use 'repo'
109-
if "url" in repo_data and "repo" not in repo_data:
110-
normalized = repo_data.copy()
109+
_, normalized = migrate_repo_entry(repo_data)
110+
if "url" in normalized and "repo" not in normalized:
111+
normalized = normalized.copy()
111112
normalized["repo"] = normalized.pop("url")
112-
return normalized
113-
# Already in correct format or has other fields
114-
return repo_data
113+
return t.cast("dict[str, t.Any]", normalized)
115114
# Return as-is for other types
116115
return t.cast("dict[str, t.Any]", repo_data)
117116

@@ -208,7 +207,11 @@ def format_config(config_data: dict[str, t.Any]) -> tuple[dict[str, t.Any], int]
208207

209208
for repo_name in sorted(repos.keys()):
210209
repo_data = repos[repo_name]
211-
action, result = _classify_fmt_action(repo_data)
210+
try:
211+
action, result = _classify_fmt_action(repo_data)
212+
except (TypeError, ValueError) as error:
213+
msg = f"{directory!r} -> {repo_name!r} -> {error}"
214+
raise ValueError(msg) from error
212215
if action == FmtAction.SKIP_PINNED:
213216
formatted_dir[repo_name] = copy.deepcopy(result)
214217
else:
@@ -342,7 +345,11 @@ def format_single_config(
342345
for message in duplicate_merge_conflicts:
343346
log.warning(message)
344347

345-
formatted_config, change_count = format_config(normalized_config)
348+
try:
349+
formatted_config, change_count = format_config(normalized_config)
350+
except ValueError as error:
351+
log.error("%s: %s", display_config_path, error) # noqa: TRY400
352+
return False
346353
change_count += normalization_changes + duplicate_merge_changes
347354

348355
if change_count == 0:

‎src/vcspull/cli/import_cmd/_common.py‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
get_pin_reason,
3737
is_pinned_for_op,
3838
merge_duplicate_workspace_roots,
39+
migrate_repo_entry,
3940
normalize_config_file_path,
4041
save_config,
4142
workspace_root_label,
@@ -456,7 +457,7 @@ def _create_shared_parent() -> argparse.ArgumentParser:
456457
"Sync config with remote: update URLs for existing entries whose "
457458
"URL has changed, and remove entries no longer on the remote. "
458459
"Preserves all metadata (options, remotes, shell_command_after). "
459-
"Respects pinned entries (options.pin.import)."
460+
"Respects pinned entries (pin.import)."
460461
),
461462
)
462463
output_group.add_argument(
@@ -666,8 +667,8 @@ def _run_import(
666667
Exclude repos whose owner path contains any of these group name segments
667668
sync : bool
668669
Sync existing config entries whose URL has changed
669-
(default: False). Entries with ``options.pin.import`` or
670-
``options.allow_overwrite: false`` are exempt.
670+
(default: False). Entries with ``pin.import`` or
671+
``allow_overwrite: false`` are exempt.
671672
prune : bool
672673
Remove config entries tagged by a previous import that are
673674
no longer on the remote (default: False). Does not update
@@ -1060,6 +1061,17 @@ def _run_import(
10601061
if isinstance(existing_raw, dict)
10611062
else {}
10621063
)
1064+
try:
1065+
_, updated = migrate_repo_entry(updated)
1066+
except (TypeError, ValueError) as error:
1067+
log.error( # noqa: TRY400
1068+
"%s: %r -> %r -> %s",
1069+
display_config_path,
1070+
repo_workspace_label,
1071+
repo.name,
1072+
error,
1073+
)
1074+
return 1
10631075
updated["repo"] = incoming_url
10641076
updated.pop("url", None)
10651077
if import_source:
@@ -1085,8 +1097,18 @@ def _run_import(
10851097
)
10861098
if needs_tag:
10871099
if not dry_run:
1088-
# None is not safe: setdefault returns existing
1089-
# None instead of replacing it, causing TypeError.
1100+
try:
1101+
_, live = migrate_repo_entry(live)
1102+
except (TypeError, ValueError) as error:
1103+
log.error( # noqa: TRY400
1104+
"%s: %r -> %r -> %s",
1105+
display_config_path,
1106+
repo_workspace_label,
1107+
repo.name,
1108+
error,
1109+
)
1110+
return 1
1111+
raw_config[repo_workspace_label][repo.name] = live
10901112
if not isinstance(existing_meta, dict):
10911113
live["metadata"] = {}
10921114
live.setdefault("metadata", {})["imported_from"] = (

‎src/vcspull/config.py‎

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,10 @@ def find_home_config_files(
366366
367367
Examples
368368
--------
369-
>>> find_home_config_files()
369+
>>> with tempfile.TemporaryDirectory() as home:
370+
... with getfixture("monkeypatch").context() as patch:
371+
... patch.setenv("HOME", home)
372+
... find_home_config_files()
370373
[]
371374
"""
372375
if filetype is None:
@@ -1021,19 +1024,19 @@ def build_repo_entry(
10211024
"""Build a raw per-repository config entry for ``add``/``discover``.
10221025
10231026
Centralizes the entry shape written by both subcommands so the recorded
1024-
keys stay consistent. Sync-tuning keys are nested under ``options:``;
1027+
keys stay consistent. Targets use ``working_copy`` and clone settings use ``git``;
10251028
``depth`` wins over ``shallow`` when both are supplied.
10261029
10271030
Parameters
10281031
----------
10291032
url : str
10301033
VCS URL in vcspull format, e.g. ``git+https://github.com/u/r.git``.
10311034
rev : str | None
1032-
Commit, tag, or branch to pin via ``options.rev``. Omitted when falsy.
1035+
Commit, tag, or branch to pin via ``working_copy.rev``. Omitted when falsy.
10331036
shallow : bool
1034-
If ``True``, record ``options.shallow: true`` (clone ``--depth 1``).
1037+
If ``True``, record ``git.depth: 1`` (clone ``--depth 1``).
10351038
depth : int | None
1036-
If set, record ``options.depth: N`` (clone ``--depth N``).
1039+
If set, record ``git.depth: N`` (clone ``--depth N``).
10371040
10381041
Returns
10391042
-------
@@ -1046,35 +1049,33 @@ def build_repo_entry(
10461049
{'repo': 'git+https://github.com/u/r.git'}
10471050
10481051
>>> build_repo_entry("git+https://github.com/u/r.git", rev="v1.0.0")
1049-
{'repo': 'git+https://github.com/u/r.git', 'options': {'rev': 'v1.0.0'}}
1052+
{'repo': 'git+https://github.com/u/r.git', 'working_copy': {'rev': 'v1.0.0'}}
10501053
10511054
>>> build_repo_entry("git+https://github.com/u/r.git", shallow=True)
1052-
{'repo': 'git+https://github.com/u/r.git', 'options': {'shallow': True}}
1055+
{'repo': 'git+https://github.com/u/r.git', 'git': {'depth': 1}}
10531056
10541057
>>> build_repo_entry("git+https://github.com/u/r.git", depth=50)
1055-
{'repo': 'git+https://github.com/u/r.git', 'options': {'depth': 50}}
1058+
{'repo': 'git+https://github.com/u/r.git', 'git': {'depth': 50}}
10561059
10571060
``depth`` wins over ``shallow``:
10581061
10591062
>>> build_repo_entry("git+https://github.com/u/r.git", shallow=True, depth=50)
1060-
{'repo': 'git+https://github.com/u/r.git', 'options': {'depth': 50}}
1063+
{'repo': 'git+https://github.com/u/r.git', 'git': {'depth': 50}}
10611064
"""
10621065
entry: dict[str, t.Any] = {"repo": url}
1063-
options: dict[str, t.Any] = {}
10641066
if rev:
1065-
options["rev"] = rev
1066-
if depth:
1067-
options["depth"] = depth
1067+
entry["working_copy"] = {"rev": rev}
1068+
if depth is not None:
1069+
entry["git"] = {"depth": depth}
10681070
elif shallow:
1069-
options["shallow"] = True
1070-
if options:
1071-
entry["options"] = options
1071+
entry["git"] = {"depth": 1}
1072+
validate_repo_entry(entry, location="repository")
1073+
if "working_copy" in entry:
1074+
validate_working_copy(entry["working_copy"])
10721075
return entry
10731076

10741077

1075-
#: Per-repository sync-tuning keys whose canonical home is the ``options:``
1076-
#: block. They were accepted at the entry root in v1.61.0; that form is now
1077-
#: deprecated and migrated by :func:`migrate_repo_entry`.
1078+
#: Legacy flat tuning fields migrated into ``working_copy`` or ``git``.
10781079
LEGACY_REPO_OPTION_KEYS = ("rev", "shallow", "depth")
10791080

10801081

0 commit comments

Comments
 (0)