22
33from __future__ import annotations
44
5+ import argparse
6+ import logging
7+ import subprocess
58import typing as t
69
710import pytest
811
9- from vcspull .cli .add import add_repo
12+ from vcspull .cli .add import add_repo , handle_add_command
13+ from vcspull .util import contract_user_home
1014
1115if t .TYPE_CHECKING :
1216 import pathlib
@@ -29,6 +33,17 @@ class AddRepoFixture(t.NamedTuple):
2933 expected_log_messages : list [str ]
3034
3135
36+ def init_git_repo (repo_path : pathlib .Path , remote_url : str | None ) -> None :
37+ """Initialize a git repository with an optional origin remote."""
38+ repo_path .mkdir (parents = True , exist_ok = True )
39+ subprocess .run (["git" , "init" , "-q" , str (repo_path )], check = True )
40+ if remote_url :
41+ subprocess .run (
42+ ["git" , "-C" , str (repo_path ), "remote" , "add" , "origin" , remote_url ],
43+ check = True ,
44+ )
45+
46+
3247ADD_REPO_FIXTURES : list [AddRepoFixture ] = [
3348 AddRepoFixture (
3449 test_id = "simple-add-default-workspace" ,
@@ -297,3 +312,210 @@ def test_add_repo_creates_new_file(
297312
298313 assert "./" in config
299314 assert "newrepo" in config ["./" ]
315+
316+
317+ def test_add_repo_merges_duplicate_workspace_roots (
318+ tmp_path : pathlib .Path ,
319+ monkeypatch : MonkeyPatch ,
320+ caplog : t .Any ,
321+ ) -> None :
322+ """Duplicate workspace roots are merged without losing repositories."""
323+ import yaml
324+
325+ caplog .set_level (logging .INFO )
326+
327+ monkeypatch .setenv ("HOME" , str (tmp_path ))
328+ monkeypatch .chdir (tmp_path )
329+
330+ config_file = tmp_path / ".vcspull.yaml"
331+ config_file .write_text (
332+ (
333+ "~/study/python/:\n "
334+ " repo1:\n "
335+ " repo: git+https://example.com/repo1.git\n "
336+ "~/study/python/:\n "
337+ " repo2:\n "
338+ " repo: git+https://example.com/repo2.git\n "
339+ ),
340+ encoding = "utf-8" ,
341+ )
342+
343+ add_repo (
344+ name = "pytest-docker" ,
345+ url = "git+https://github.com/avast/pytest-docker.git" ,
346+ config_file_path_str = str (config_file ),
347+ path = str (tmp_path / "study/python/pytest-docker" ),
348+ workspace_root_path = "~/study/python/" ,
349+ dry_run = False ,
350+ )
351+
352+ with config_file .open () as fh :
353+ merged_config = yaml .safe_load (fh )
354+
355+ assert "~/study/python/" in merged_config
356+ repos = merged_config ["~/study/python/" ]
357+ assert set (repos .keys ()) == {"repo1" , "repo2" , "pytest-docker" }
358+
359+ assert "Merged" in caplog .text
360+
361+
362+ class PathAddFixture (t .NamedTuple ):
363+ """Fixture describing CLI path-mode add scenarios."""
364+
365+ test_id : str
366+ remote_url : str | None
367+ assume_yes : bool
368+ prompt_response : str | None
369+ dry_run : bool
370+ expected_written : bool
371+ expected_url_kind : str # "remote" or "path"
372+ override_name : str | None
373+ expected_warning : str | None
374+
375+
376+ PATH_ADD_FIXTURES : list [PathAddFixture ] = [
377+ PathAddFixture (
378+ test_id = "path-auto-confirm" ,
379+ remote_url = "https://github.com/avast/pytest-docker" ,
380+ assume_yes = True ,
381+ prompt_response = None ,
382+ dry_run = False ,
383+ expected_written = True ,
384+ expected_url_kind = "remote" ,
385+ override_name = None ,
386+ expected_warning = None ,
387+ ),
388+ PathAddFixture (
389+ test_id = "path-interactive-accept" ,
390+ remote_url = "https://github.com/example/project" ,
391+ assume_yes = False ,
392+ prompt_response = "y" ,
393+ dry_run = False ,
394+ expected_written = True ,
395+ expected_url_kind = "remote" ,
396+ override_name = "project-alias" ,
397+ expected_warning = None ,
398+ ),
399+ PathAddFixture (
400+ test_id = "path-interactive-decline" ,
401+ remote_url = "https://github.com/example/decline" ,
402+ assume_yes = False ,
403+ prompt_response = "n" ,
404+ dry_run = False ,
405+ expected_written = False ,
406+ expected_url_kind = "remote" ,
407+ override_name = None ,
408+ expected_warning = None ,
409+ ),
410+ PathAddFixture (
411+ test_id = "path-no-remote" ,
412+ remote_url = None ,
413+ assume_yes = True ,
414+ prompt_response = None ,
415+ dry_run = False ,
416+ expected_written = True ,
417+ expected_url_kind = "path" ,
418+ override_name = None ,
419+ expected_warning = "Unable to determine git remote" ,
420+ ),
421+ ]
422+
423+
424+ @pytest .mark .parametrize (
425+ list (PathAddFixture ._fields ),
426+ PATH_ADD_FIXTURES ,
427+ ids = [fixture .test_id for fixture in PATH_ADD_FIXTURES ],
428+ )
429+ def test_handle_add_command_path_mode (
430+ test_id : str ,
431+ remote_url : str | None ,
432+ assume_yes : bool ,
433+ prompt_response : str | None ,
434+ dry_run : bool ,
435+ expected_written : bool ,
436+ expected_url_kind : str ,
437+ override_name : str | None ,
438+ expected_warning : str | None ,
439+ tmp_path : pathlib .Path ,
440+ monkeypatch : MonkeyPatch ,
441+ caplog : t .Any ,
442+ ) -> None :
443+ """CLI path mode prompts and adds repositories appropriately."""
444+ caplog .set_level (logging .INFO )
445+
446+ monkeypatch .setenv ("HOME" , str (tmp_path ))
447+ monkeypatch .chdir (tmp_path )
448+
449+ repo_path = tmp_path / "study/python/pytest-docker"
450+ init_git_repo (repo_path , remote_url )
451+
452+ config_file = tmp_path / ".vcspull.yaml"
453+
454+ expected_input = prompt_response if prompt_response is not None else "y"
455+ monkeypatch .setattr ("builtins.input" , lambda _ : expected_input )
456+
457+ args = argparse .Namespace (
458+ target = str (repo_path ),
459+ url = None ,
460+ override_name = override_name ,
461+ config = str (config_file ),
462+ path = None ,
463+ workspace_root_path = None ,
464+ dry_run = dry_run ,
465+ assume_yes = assume_yes ,
466+ )
467+
468+ handle_add_command (args )
469+
470+ log_output = caplog .text
471+ contracted_path = contract_user_home (repo_path )
472+
473+ assert "Found new repository to import" in log_output
474+ assert contracted_path in log_output
475+
476+ if dry_run :
477+ assert "skipped (dry-run)" in log_output
478+
479+ if assume_yes :
480+ assert "auto-confirm" in log_output
481+
482+ if expected_warning is not None :
483+ assert expected_warning in log_output
484+
485+ repo_name = override_name or repo_path .name
486+
487+ if expected_written :
488+ import yaml
489+
490+ assert config_file .exists ()
491+ with config_file .open (encoding = "utf-8" ) as fh :
492+ config_data = yaml .safe_load (fh )
493+
494+ workspace = "~/study/python/"
495+ assert workspace in config_data
496+ assert repo_name in config_data [workspace ]
497+
498+ repo_entry = config_data [workspace ][repo_name ]
499+ expected_url : str
500+ if expected_url_kind == "remote" and remote_url is not None :
501+ cleaned_remote = remote_url .strip ()
502+ expected_url = (
503+ cleaned_remote
504+ if cleaned_remote .startswith ("git+" )
505+ else f"git+{ cleaned_remote } "
506+ )
507+ else :
508+ expected_url = str (repo_path )
509+
510+ assert repo_entry == {"repo" : expected_url }
511+ else :
512+ if config_file .exists ():
513+ import yaml
514+
515+ with config_file .open (encoding = "utf-8" ) as fh :
516+ config_data = yaml .safe_load (fh )
517+ if config_data is not None :
518+ workspace = config_data .get ("~/study/python/" )
519+ if workspace is not None :
520+ assert repo_name not in workspace
521+ assert "Aborted import" in log_output
0 commit comments