Skip to content

Commit a3938dd

Browse files
committed
fix(import[provenance]) Guard against metadata: null in provenance stamping
why: When metadata is null (YAML null → Python None), the guard `isinstance(x, (dict, type(None)))` lets None pass through. Then `setdefault("metadata", {})` returns the existing None value, causing TypeError on assignment. what: - Remove type(None) from isinstance checks on lines 956 and 969 - None metadata is now replaced with {} like any other non-dict value - Add tests for null metadata in both SKIP_UNCHANGED and UPDATE_URL paths
1 parent cedbae6 commit a3938dd

2 files changed

Lines changed: 110 additions & 2 deletions

File tree

src/vcspull/cli/import_cmd/_common.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -953,7 +953,7 @@ def _run_import(
953953
updated["repo"] = incoming_url
954954
updated.pop("url", None)
955955
if import_source:
956-
if not isinstance(updated.get("metadata"), (dict, type(None))):
956+
if not isinstance(updated.get("metadata"), dict):
957957
updated["metadata"] = {}
958958
metadata = updated.setdefault("metadata", {})
959959
metadata["imported_from"] = import_source
@@ -966,7 +966,7 @@ def _run_import(
966966
if not dry_run and import_source:
967967
live = raw_config[repo_workspace_label].get(repo.name)
968968
if isinstance(live, dict):
969-
if not isinstance(live.get("metadata"), (dict, type(None))):
969+
if not isinstance(live.get("metadata"), dict):
970970
live["metadata"] = {}
971971
live.setdefault("metadata", {})["imported_from"] = import_source
972972
provenance_tagged_count += 1

tests/cli/test_import_repos.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3597,6 +3597,114 @@ def test_import_provenance_survives_non_dict_metadata(
35973597
assert entry["metadata"]["imported_from"] == "github:testuser"
35983598

35993599

3600+
def test_import_provenance_survives_null_metadata(
3601+
tmp_path: pathlib.Path,
3602+
monkeypatch: MonkeyPatch,
3603+
caplog: pytest.LogCaptureFixture,
3604+
) -> None:
3605+
"""Provenance stamping replaces null metadata with a proper dict (SKIP path)."""
3606+
caplog.set_level(logging.INFO)
3607+
monkeypatch.setenv("HOME", str(tmp_path))
3608+
workspace = tmp_path / "repos"
3609+
workspace.mkdir()
3610+
config_file = tmp_path / ".vcspull.yaml"
3611+
3612+
# Existing entry with metadata: null (YAML null → Python None)
3613+
save_config_yaml(
3614+
config_file,
3615+
{"~/repos/": {"repo1": {"repo": _SSH, "metadata": None}}},
3616+
)
3617+
3618+
importer = MockImporter(repos=[_make_repo("repo1")])
3619+
_run_import(
3620+
importer,
3621+
service_name="github",
3622+
target="testuser",
3623+
workspace=str(workspace),
3624+
mode="user",
3625+
language=None,
3626+
topics=None,
3627+
min_stars=0,
3628+
include_archived=False,
3629+
include_forks=False,
3630+
limit=100,
3631+
config_path_str=str(config_file),
3632+
dry_run=False,
3633+
yes=True,
3634+
output_json=False,
3635+
output_ndjson=False,
3636+
color="never",
3637+
sync=True,
3638+
import_source="github:testuser",
3639+
)
3640+
3641+
from vcspull._internal.config_reader import ConfigReader
3642+
3643+
final_config = ConfigReader._from_file(config_file)
3644+
assert final_config is not None
3645+
entry = final_config["~/repos/"]["repo1"]
3646+
assert isinstance(entry["metadata"], dict)
3647+
assert entry["metadata"]["imported_from"] == "github:testuser"
3648+
3649+
3650+
def test_import_update_url_survives_null_metadata(
3651+
tmp_path: pathlib.Path,
3652+
monkeypatch: MonkeyPatch,
3653+
caplog: pytest.LogCaptureFixture,
3654+
) -> None:
3655+
"""Provenance stamping replaces null metadata with a proper dict (UPDATE path)."""
3656+
caplog.set_level(logging.INFO)
3657+
monkeypatch.setenv("HOME", str(tmp_path))
3658+
workspace = tmp_path / "repos"
3659+
workspace.mkdir()
3660+
config_file = tmp_path / ".vcspull.yaml"
3661+
3662+
# Existing entry with a different URL and metadata: null
3663+
save_config_yaml(
3664+
config_file,
3665+
{
3666+
"~/repos/": {
3667+
"repo1": {
3668+
"repo": "git+git@github.com:testuser/repo1-OLD.git",
3669+
"metadata": None,
3670+
},
3671+
},
3672+
},
3673+
)
3674+
3675+
importer = MockImporter(repos=[_make_repo("repo1")])
3676+
_run_import(
3677+
importer,
3678+
service_name="github",
3679+
target="testuser",
3680+
workspace=str(workspace),
3681+
mode="user",
3682+
language=None,
3683+
topics=None,
3684+
min_stars=0,
3685+
include_archived=False,
3686+
include_forks=False,
3687+
limit=100,
3688+
config_path_str=str(config_file),
3689+
dry_run=False,
3690+
yes=True,
3691+
output_json=False,
3692+
output_ndjson=False,
3693+
color="never",
3694+
sync=True,
3695+
import_source="github:testuser",
3696+
)
3697+
3698+
from vcspull._internal.config_reader import ConfigReader
3699+
3700+
final_config = ConfigReader._from_file(config_file)
3701+
assert final_config is not None
3702+
entry = final_config["~/repos/"]["repo1"]
3703+
assert isinstance(entry["metadata"], dict)
3704+
assert entry["metadata"]["imported_from"] == "github:testuser"
3705+
assert entry["repo"] == _SSH
3706+
3707+
36003708
# ---------------------------------------------------------------------------
36013709
# --prune standalone flag tests
36023710
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)